@lobehub/lobehub 2.0.0-next.231 → 2.0.0-next.233
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/.github/workflows/bundle-analyzer.yml +1 -1
- package/.github/workflows/e2e.yml +67 -52
- package/.github/workflows/manual-build-desktop.yml +5 -5
- package/.github/workflows/pr-build-desktop.yml +4 -4
- package/.github/workflows/pr-build-docker.yml +2 -2
- package/.github/workflows/release-desktop-beta.yml +4 -4
- package/.github/workflows/release-docker.yml +2 -2
- package/.github/workflows/test.yml +44 -7
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +14 -0
- package/docs/self-hosting/environment-variables/auth.mdx +7 -0
- package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +7 -0
- package/package.json +1 -1
- package/packages/business/config/src/llm.ts +6 -1
- package/packages/const/src/settings/image.ts +1 -1
- package/packages/model-bank/src/aiModels/azure.ts +2 -2
- package/packages/model-bank/src/aiModels/google.ts +1 -0
- package/packages/model-bank/src/aiModels/lobehub.ts +33 -13
- package/packages/model-bank/src/aiModels/openai.ts +21 -4
- package/packages/model-runtime/src/core/openaiCompatibleFactory/createImage.ts +4 -1
- package/packages/model-runtime/src/providers/openai/__snapshots__/index.test.ts.snap +1 -1
- package/packages/ssrf-safe-fetch/index.test.ts +5 -34
- package/packages/ssrf-safe-fetch/index.ts +12 -2
- package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/MultiImagesUpload/index.tsx +3 -3
- package/src/app/[variants]/(main)/image/features/GenerationFeed/index.tsx +3 -10
- package/src/app/[variants]/(main)/image/index.tsx +1 -1
- package/src/components/Loading/BrandTextLoading/index.module.css +81 -0
- package/src/components/Loading/BrandTextLoading/index.tsx +24 -17
- package/src/envs/auth.ts +15 -0
- package/src/hooks/useFetchAiImageConfig.ts +54 -10
- package/src/libs/redis/manager.ts +63 -0
- package/src/libs/trpc/utils/internalJwt.ts +2 -2
- package/src/server/services/agent/index.test.ts +15 -8
- package/src/server/services/agent/index.ts +11 -4
- package/src/store/image/slices/generationConfig/initialState.ts +5 -5
- package/src/store/image/slices/generationConfig/selectors.test.ts +11 -4
- package/vitest.config.mts +10 -6
|
@@ -1219,6 +1219,26 @@ export const openaiSTTModels: AISTTModelCard[] = [
|
|
|
1219
1219
|
|
|
1220
1220
|
// Image generation models
|
|
1221
1221
|
export const openaiImageModels: AIImageModelCard[] = [
|
|
1222
|
+
{
|
|
1223
|
+
description:
|
|
1224
|
+
'An enhanced GPT Image 1 model with 4× faster generation, more precise editing, and improved text rendering.',
|
|
1225
|
+
displayName: 'GPT Image 1.5',
|
|
1226
|
+
enabled: true,
|
|
1227
|
+
id: 'gpt-image-1.5',
|
|
1228
|
+
parameters: gptImage1ParamsSchema,
|
|
1229
|
+
pricing: {
|
|
1230
|
+
approximatePricePerImage: 0.034,
|
|
1231
|
+
units: [
|
|
1232
|
+
{ name: 'textInput', rate: 5, strategy: 'fixed', unit: 'millionTokens' },
|
|
1233
|
+
{ name: 'textInput_cacheRead', rate: 1.25, strategy: 'fixed', unit: 'millionTokens' },
|
|
1234
|
+
{ name: 'imageInput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
|
|
1235
|
+
{ name: 'imageInput_cacheRead', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
|
1236
|
+
{ name: 'imageOutput', rate: 32, strategy: 'fixed', unit: 'millionTokens' },
|
|
1237
|
+
],
|
|
1238
|
+
},
|
|
1239
|
+
releasedAt: '2025-12-16',
|
|
1240
|
+
type: 'image',
|
|
1241
|
+
},
|
|
1222
1242
|
// https://platform.openai.com/docs/models/gpt-image-1
|
|
1223
1243
|
{
|
|
1224
1244
|
description: 'ChatGPT native multimodal image generation model.',
|
|
@@ -1236,7 +1256,6 @@ export const openaiImageModels: AIImageModelCard[] = [
|
|
|
1236
1256
|
{ name: 'imageOutput', rate: 40, strategy: 'fixed', unit: 'millionTokens' },
|
|
1237
1257
|
],
|
|
1238
1258
|
},
|
|
1239
|
-
resolutions: ['1024x1024', '1024x1536', '1536x1024'],
|
|
1240
1259
|
type: 'image',
|
|
1241
1260
|
},
|
|
1242
1261
|
{
|
|
@@ -1257,13 +1276,13 @@ export const openaiImageModels: AIImageModelCard[] = [
|
|
|
1257
1276
|
],
|
|
1258
1277
|
},
|
|
1259
1278
|
releasedAt: '2025-10-06',
|
|
1260
|
-
resolutions: ['1024x1024', '1024x1536', '1536x1024'],
|
|
1261
1279
|
type: 'image',
|
|
1262
1280
|
},
|
|
1263
1281
|
{
|
|
1264
1282
|
description:
|
|
1265
1283
|
'The latest DALL·E model, released in November 2023, supports more realistic, accurate image generation with stronger detail.',
|
|
1266
1284
|
displayName: 'DALL·E 3',
|
|
1285
|
+
enabled: true,
|
|
1267
1286
|
id: 'dall-e-3',
|
|
1268
1287
|
parameters: {
|
|
1269
1288
|
prompt: { default: '' },
|
|
@@ -1296,7 +1315,6 @@ export const openaiImageModels: AIImageModelCard[] = [
|
|
|
1296
1315
|
},
|
|
1297
1316
|
],
|
|
1298
1317
|
},
|
|
1299
|
-
resolutions: ['1024x1024', '1024x1792', '1792x1024'],
|
|
1300
1318
|
type: 'image',
|
|
1301
1319
|
},
|
|
1302
1320
|
{
|
|
@@ -1329,7 +1347,6 @@ export const openaiImageModels: AIImageModelCard[] = [
|
|
|
1329
1347
|
},
|
|
1330
1348
|
],
|
|
1331
1349
|
},
|
|
1332
|
-
resolutions: ['256x256', '512x512', '1024x1024'],
|
|
1333
1350
|
type: 'image',
|
|
1334
1351
|
},
|
|
1335
1352
|
];
|
|
@@ -67,7 +67,10 @@ async function generateByImageMode(
|
|
|
67
67
|
const defaultInput = {
|
|
68
68
|
n: 1,
|
|
69
69
|
...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
|
|
70
|
-
|
|
70
|
+
// https://platform.openai.com/docs/api-reference/images/createEdit#images_createedit-input_fidelity
|
|
71
|
+
...(isImageEdit && model.includes('gpt-image-') && !model.includes('mini')
|
|
72
|
+
? { input_fidelity: 'high' }
|
|
73
|
+
: {}),
|
|
71
74
|
};
|
|
72
75
|
|
|
73
76
|
const options = cleanObject({
|
|
@@ -311,7 +311,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
|
|
311
311
|
"contextWindowTokens": undefined,
|
|
312
312
|
"description": "The latest DALL·E model, released in November 2023, supports more realistic, accurate image generation with stronger detail.",
|
|
313
313
|
"displayName": "DALL·E 3",
|
|
314
|
-
"enabled":
|
|
314
|
+
"enabled": true,
|
|
315
315
|
"functionCall": false,
|
|
316
316
|
"id": "dall-e-3",
|
|
317
317
|
"imageOutput": false,
|
|
@@ -49,14 +49,7 @@ describe('ssrfSafeFetch', () => {
|
|
|
49
49
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
50
50
|
'https://httpbin.org/get',
|
|
51
51
|
expect.objectContaining({
|
|
52
|
-
agent: expect.
|
|
53
|
-
requestFilterOptions: expect.objectContaining({
|
|
54
|
-
allowIPAddressList: [],
|
|
55
|
-
allowMetaIPAddress: false,
|
|
56
|
-
allowPrivateIPAddress: false,
|
|
57
|
-
denyIPAddressList: [],
|
|
58
|
-
}),
|
|
59
|
-
}),
|
|
52
|
+
agent: expect.any(Function),
|
|
60
53
|
}),
|
|
61
54
|
);
|
|
62
55
|
expect(response).toBeInstanceOf(Response);
|
|
@@ -80,14 +73,7 @@ describe('ssrfSafeFetch', () => {
|
|
|
80
73
|
'https://httpbin.org/post',
|
|
81
74
|
expect.objectContaining({
|
|
82
75
|
...requestOptions,
|
|
83
|
-
agent: expect.
|
|
84
|
-
requestFilterOptions: expect.objectContaining({
|
|
85
|
-
allowIPAddressList: [],
|
|
86
|
-
allowMetaIPAddress: false,
|
|
87
|
-
allowPrivateIPAddress: false,
|
|
88
|
-
denyIPAddressList: [],
|
|
89
|
-
}),
|
|
90
|
-
}),
|
|
76
|
+
agent: expect.any(Function),
|
|
91
77
|
}),
|
|
92
78
|
);
|
|
93
79
|
});
|
|
@@ -302,14 +288,7 @@ describe('ssrfSafeFetch', () => {
|
|
|
302
288
|
'https://api.example.com/data',
|
|
303
289
|
expect.objectContaining({
|
|
304
290
|
...requestOptions,
|
|
305
|
-
agent: expect.
|
|
306
|
-
requestFilterOptions: expect.objectContaining({
|
|
307
|
-
allowIPAddressList: ['127.0.0.1'],
|
|
308
|
-
allowMetaIPAddress: true,
|
|
309
|
-
allowPrivateIPAddress: true,
|
|
310
|
-
denyIPAddressList: [],
|
|
311
|
-
}),
|
|
312
|
-
}),
|
|
291
|
+
agent: expect.any(Function),
|
|
313
292
|
}),
|
|
314
293
|
);
|
|
315
294
|
|
|
@@ -323,19 +302,11 @@ describe('ssrfSafeFetch', () => {
|
|
|
323
302
|
|
|
324
303
|
await ssrfSafeFetch('https://secure.example.com/api');
|
|
325
304
|
|
|
326
|
-
// Verify that the agent is
|
|
305
|
+
// Verify that the agent function is passed
|
|
327
306
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
328
307
|
'https://secure.example.com/api',
|
|
329
308
|
expect.objectContaining({
|
|
330
|
-
agent: expect.
|
|
331
|
-
protocol: 'https:',
|
|
332
|
-
requestFilterOptions: expect.objectContaining({
|
|
333
|
-
allowIPAddressList: [],
|
|
334
|
-
allowMetaIPAddress: false,
|
|
335
|
-
allowPrivateIPAddress: false,
|
|
336
|
-
denyIPAddressList: [],
|
|
337
|
-
}),
|
|
338
|
-
}),
|
|
309
|
+
agent: expect.any(Function),
|
|
339
310
|
}),
|
|
340
311
|
);
|
|
341
312
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
RequestFilteringAgentOptions,
|
|
4
|
+
RequestFilteringHttpAgent,
|
|
5
|
+
RequestFilteringHttpsAgent,
|
|
6
|
+
} from 'request-filtering-agent';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Options for per-call SSRF configuration overrides
|
|
@@ -42,10 +46,16 @@ export const ssrfSafeFetch = async (
|
|
|
42
46
|
denyIPAddressList: [],
|
|
43
47
|
};
|
|
44
48
|
|
|
49
|
+
// Create agents for both protocols
|
|
50
|
+
const httpAgent = new RequestFilteringHttpAgent(agentOptions);
|
|
51
|
+
const httpsAgent = new RequestFilteringHttpsAgent(agentOptions);
|
|
52
|
+
|
|
45
53
|
// Use node-fetch with SSRF protection agent
|
|
54
|
+
// Pass a function to dynamically select agent based on URL protocol
|
|
55
|
+
// This handles redirects from HTTP to HTTPS correctly
|
|
46
56
|
const response = await fetch(url, {
|
|
47
57
|
...options,
|
|
48
|
-
agent:
|
|
58
|
+
agent: (parsedURL: URL) => (parsedURL.protocol === 'https:' ? httpsAgent : httpAgent),
|
|
49
59
|
} as any);
|
|
50
60
|
|
|
51
61
|
// Convert node-fetch Response to standard Response
|
package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/MultiImagesUpload/index.tsx
CHANGED
|
@@ -93,8 +93,8 @@ const styles = createStaticStyles(({ css }) => {
|
|
|
93
93
|
|
|
94
94
|
overflow: hidden;
|
|
95
95
|
|
|
96
|
-
width: ${thumbnailSize};
|
|
97
|
-
height: ${thumbnailSize};
|
|
96
|
+
width: ${thumbnailSize}px;
|
|
97
|
+
height: ${thumbnailSize}px;
|
|
98
98
|
border-radius: ${cssVar.borderRadius};
|
|
99
99
|
|
|
100
100
|
background: ${cssVar.colorBgContainer};
|
|
@@ -112,7 +112,7 @@ const styles = createStaticStyles(({ css }) => {
|
|
|
112
112
|
gap: 8px;
|
|
113
113
|
|
|
114
114
|
width: 100%;
|
|
115
|
-
height: ${thumbnailSize};
|
|
115
|
+
height: ${thumbnailSize}px;
|
|
116
116
|
padding: 0;
|
|
117
117
|
border-radius: ${cssVar.borderRadiusLG};
|
|
118
118
|
|
|
@@ -77,15 +77,8 @@ const GenerationFeed = memo(() => {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
return (
|
|
80
|
-
|
|
81
|
-
<Flexbox
|
|
82
|
-
gap={16}
|
|
83
|
-
ref={parent}
|
|
84
|
-
style={{
|
|
85
|
-
minHeight: 'calc(100vh - 180px)',
|
|
86
|
-
}}
|
|
87
|
-
width="100%"
|
|
88
|
-
>
|
|
80
|
+
<Flexbox flex={1}>
|
|
81
|
+
<Flexbox gap={16} ref={parent} width="100%">
|
|
89
82
|
{currentGenerationBatches.map((batch, index) => (
|
|
90
83
|
<Fragment key={batch.id}>
|
|
91
84
|
{Boolean(index !== 0) && <Divider dashed style={{ margin: 0 }} />}
|
|
@@ -95,7 +88,7 @@ const GenerationFeed = memo(() => {
|
|
|
95
88
|
</Flexbox>
|
|
96
89
|
{/* Invisible element for scroll target */}
|
|
97
90
|
<div ref={containerRef} style={{ height: 1 }} />
|
|
98
|
-
|
|
91
|
+
</Flexbox>
|
|
99
92
|
);
|
|
100
93
|
});
|
|
101
94
|
|
|
@@ -15,7 +15,7 @@ const DesktopImagePage = memo(() => {
|
|
|
15
15
|
<>
|
|
16
16
|
<NavHeader right={<WideScreenButton />} />
|
|
17
17
|
<Flexbox height={'100%'} style={{ overflowY: 'auto', position: 'relative' }} width={'100%'}>
|
|
18
|
-
<WideScreenContainer>
|
|
18
|
+
<WideScreenContainer height={'100%'} wrapperStyle={{ height: '100%' }}>
|
|
19
19
|
<Suspense fallback={<SkeletonList />}>
|
|
20
20
|
<ImageWorkspace />
|
|
21
21
|
</Suspense>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
--brand-text-color: var(--colorText, #1f1f1f);
|
|
3
|
+
--brand-muted-color: var(--colorTextSecondary, #8c8c8c);
|
|
4
|
+
--brand-border-color: var(--colorBorder, #d9d9d9);
|
|
5
|
+
--brand-tag-bg: var(--colorFillTertiary, rgba(0, 0, 0, 0.04));
|
|
6
|
+
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100vh;
|
|
14
|
+
height: 100dvh;
|
|
15
|
+
gap: 12px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
[data-theme='dark'] .container {
|
|
19
|
+
--brand-text-color: var(--colorText, #f0f0f0);
|
|
20
|
+
--brand-muted-color: var(--colorTextSecondary, #a6a6a6);
|
|
21
|
+
--brand-border-color: var(--colorBorder, #424242);
|
|
22
|
+
--brand-tag-bg: var(--colorFillTertiary, rgba(255, 255, 255, 0.08));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.brand {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: 12px;
|
|
29
|
+
|
|
30
|
+
opacity: 0.6;
|
|
31
|
+
color: var(--brand-text-color);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.brand :global(.lobe-brand-loading) {
|
|
35
|
+
display: block;
|
|
36
|
+
color: inherit;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.debug {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 4px;
|
|
44
|
+
|
|
45
|
+
padding: 16px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.debugRow {
|
|
49
|
+
display: flex;
|
|
50
|
+
gap: 8px;
|
|
51
|
+
align-items: center;
|
|
52
|
+
|
|
53
|
+
font-size: 12px;
|
|
54
|
+
color: var(--brand-muted-color);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.debugRow code {
|
|
58
|
+
font-family:
|
|
59
|
+
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
|
60
|
+
monospace;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.debugTag {
|
|
64
|
+
display: inline-flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
|
|
67
|
+
padding: 2px 8px;
|
|
68
|
+
border: 1px solid var(--brand-border-color);
|
|
69
|
+
border-radius: 6px;
|
|
70
|
+
|
|
71
|
+
background: var(--brand-tag-bg);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.debugTag code {
|
|
75
|
+
color: var(--brand-text-color);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.debugHint {
|
|
79
|
+
font-size: 12px;
|
|
80
|
+
color: var(--brand-muted-color);
|
|
81
|
+
}
|
|
@@ -1,34 +1,41 @@
|
|
|
1
|
-
import { Center, Tag, Text } from '@lobehub/ui';
|
|
2
1
|
import { BrandLoading, LobeHubText } from '@lobehub/ui/brand';
|
|
3
2
|
|
|
4
3
|
import { isCustomBranding } from '@/const/version';
|
|
5
4
|
|
|
6
5
|
import CircleLoading from '../CircleLoading';
|
|
6
|
+
import styles from './index.module.css';
|
|
7
7
|
|
|
8
8
|
interface BrandTextLoadingProps {
|
|
9
9
|
debugId: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const BrandTextLoading = ({ debugId }: BrandTextLoadingProps) => {
|
|
13
|
-
if (isCustomBranding)
|
|
13
|
+
if (isCustomBranding)
|
|
14
|
+
return (
|
|
15
|
+
<div className={styles.container}>
|
|
16
|
+
<CircleLoading />
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const showDebug = process.env.NODE_ENV === 'development' && debugId;
|
|
14
21
|
|
|
15
22
|
return (
|
|
16
|
-
<
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
</
|
|
29
|
-
</
|
|
23
|
+
<div className={styles.container}>
|
|
24
|
+
<div aria-label="Loading" className={styles.brand} role="status">
|
|
25
|
+
<BrandLoading size={40} text={LobeHubText} />
|
|
26
|
+
</div>
|
|
27
|
+
{showDebug && (
|
|
28
|
+
<div className={styles.debug}>
|
|
29
|
+
<div className={styles.debugRow}>
|
|
30
|
+
<code>Debug ID:</code>
|
|
31
|
+
<span className={styles.debugTag}>
|
|
32
|
+
<code>{debugId}</code>
|
|
33
|
+
</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div className={styles.debugHint}>only visible in development</div>
|
|
36
|
+
</div>
|
|
30
37
|
)}
|
|
31
|
-
</
|
|
38
|
+
</div>
|
|
32
39
|
);
|
|
33
40
|
};
|
|
34
41
|
|
package/src/envs/auth.ts
CHANGED
|
@@ -158,6 +158,15 @@ declare global {
|
|
|
158
158
|
* Can be generated using `node scripts/generate-oidc-jwk.mjs`.
|
|
159
159
|
*/
|
|
160
160
|
JWKS_KEY?: string;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Internal JWT expiration time for lambda → async calls.
|
|
164
|
+
* Format: number followed by unit (s=seconds, m=minutes, h=hours)
|
|
165
|
+
* Examples: '10s', '1m', '1h'
|
|
166
|
+
* Should be as short as possible for security, but long enough to account for network latency and server processing time.
|
|
167
|
+
* @default '30s'
|
|
168
|
+
*/
|
|
169
|
+
INTERNAL_JWT_EXPIRATION?: string;
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
172
|
}
|
|
@@ -285,6 +294,9 @@ export const getAuthConfig = () => {
|
|
|
285
294
|
// Generic JWKS key for signing/verifying JWTs
|
|
286
295
|
JWKS_KEY: z.string().optional(),
|
|
287
296
|
ENABLE_OIDC: z.boolean(),
|
|
297
|
+
|
|
298
|
+
// Internal JWT expiration time (e.g., '10s', '1m', '1h')
|
|
299
|
+
INTERNAL_JWT_EXPIRATION: z.string().default('30s'),
|
|
288
300
|
},
|
|
289
301
|
|
|
290
302
|
runtimeEnv: {
|
|
@@ -415,6 +427,9 @@ export const getAuthConfig = () => {
|
|
|
415
427
|
// Generic JWKS key (fallback to OIDC_JWKS_KEY for backward compatibility)
|
|
416
428
|
JWKS_KEY: process.env.JWKS_KEY || process.env.OIDC_JWKS_KEY,
|
|
417
429
|
ENABLE_OIDC: !!(process.env.JWKS_KEY || process.env.OIDC_JWKS_KEY),
|
|
430
|
+
|
|
431
|
+
// Internal JWT expiration time
|
|
432
|
+
INTERNAL_JWT_EXPIRATION: process.env.INTERNAL_JWT_EXPIRATION,
|
|
418
433
|
},
|
|
419
434
|
});
|
|
420
435
|
};
|
|
@@ -1,12 +1,26 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
1
|
+
import { useEffect, useMemo } from 'react';
|
|
2
2
|
|
|
3
3
|
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
|
|
4
4
|
import { useGlobalStore } from '@/store/global';
|
|
5
5
|
import { systemStatusSelectors } from '@/store/global/selectors';
|
|
6
6
|
import { useImageStore } from '@/store/image';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_AI_IMAGE_MODEL,
|
|
9
|
+
DEFAULT_AI_IMAGE_PROVIDER,
|
|
10
|
+
} from '@/store/image/slices/generationConfig/initialState';
|
|
7
11
|
import { useUserStore } from '@/store/user';
|
|
8
12
|
import { authSelectors } from '@/store/user/selectors';
|
|
9
13
|
|
|
14
|
+
const checkModelEnabled = (
|
|
15
|
+
enabledImageModelList: ReturnType<typeof aiProviderSelectors.enabledImageModelList>,
|
|
16
|
+
provider: string,
|
|
17
|
+
model: string,
|
|
18
|
+
) => {
|
|
19
|
+
return enabledImageModelList.some(
|
|
20
|
+
(p) => p.id === provider && p.children.some((m) => m.id === model),
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
10
24
|
export const useFetchAiImageConfig = () => {
|
|
11
25
|
const isStatusInit = useGlobalStore(systemStatusSelectors.isStatusInit);
|
|
12
26
|
const isInitAiProviderRuntimeState = useAiInfraStore(
|
|
@@ -29,16 +43,46 @@ export const useFetchAiImageConfig = () => {
|
|
|
29
43
|
const isInitializedImageConfig = useImageStore((s) => s.isInit);
|
|
30
44
|
const initializeImageConfig = useImageStore((s) => s.initializeImageConfig);
|
|
31
45
|
|
|
46
|
+
const enabledImageModelList = useAiInfraStore(aiProviderSelectors.enabledImageModelList);
|
|
47
|
+
|
|
48
|
+
// Determine which model/provider to use for initialization
|
|
49
|
+
const initParams = useMemo(() => {
|
|
50
|
+
// 1. Try lastSelected if enabled
|
|
51
|
+
if (
|
|
52
|
+
lastSelectedImageModel &&
|
|
53
|
+
lastSelectedImageProvider &&
|
|
54
|
+
checkModelEnabled(enabledImageModelList, lastSelectedImageProvider, lastSelectedImageModel)
|
|
55
|
+
) {
|
|
56
|
+
return { model: lastSelectedImageModel, provider: lastSelectedImageProvider };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Try default model from any enabled provider (prefer default provider first)
|
|
60
|
+
if (
|
|
61
|
+
checkModelEnabled(enabledImageModelList, DEFAULT_AI_IMAGE_PROVIDER, DEFAULT_AI_IMAGE_MODEL)
|
|
62
|
+
) {
|
|
63
|
+
return { model: undefined, provider: undefined }; // Use initialState defaults
|
|
64
|
+
}
|
|
65
|
+
const providerWithDefaultModel = enabledImageModelList.find((p) =>
|
|
66
|
+
p.children.some((m) => m.id === DEFAULT_AI_IMAGE_MODEL),
|
|
67
|
+
);
|
|
68
|
+
if (providerWithDefaultModel) {
|
|
69
|
+
return { model: DEFAULT_AI_IMAGE_MODEL, provider: providerWithDefaultModel.id };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Fallback to first enabled model
|
|
73
|
+
const firstProvider = enabledImageModelList[0];
|
|
74
|
+
const firstModel = firstProvider?.children[0];
|
|
75
|
+
if (firstProvider && firstModel) {
|
|
76
|
+
return { model: firstModel.id, provider: firstProvider.id };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No enabled models
|
|
80
|
+
return { model: undefined, provider: undefined };
|
|
81
|
+
}, [lastSelectedImageModel, lastSelectedImageProvider, enabledImageModelList]);
|
|
82
|
+
|
|
32
83
|
useEffect(() => {
|
|
33
84
|
if (!isInitializedImageConfig && isReadyForInit) {
|
|
34
|
-
initializeImageConfig(isLogin,
|
|
85
|
+
initializeImageConfig(isLogin, initParams.model, initParams.provider);
|
|
35
86
|
}
|
|
36
|
-
}, [
|
|
37
|
-
isReadyForInit,
|
|
38
|
-
isInitializedImageConfig,
|
|
39
|
-
isLogin,
|
|
40
|
-
lastSelectedImageModel,
|
|
41
|
-
lastSelectedImageProvider,
|
|
42
|
-
initializeImageConfig,
|
|
43
|
-
]);
|
|
87
|
+
}, [isReadyForInit, isInitializedImageConfig, isLogin, initParams, initializeImageConfig]);
|
|
44
88
|
};
|
|
@@ -94,3 +94,66 @@ export const createRedisWithPrefix = async (
|
|
|
94
94
|
await provider.initialize();
|
|
95
95
|
return provider;
|
|
96
96
|
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Manages singleton Redis clients per prefix
|
|
100
|
+
*/
|
|
101
|
+
class PrefixedRedisManager {
|
|
102
|
+
private static instances = new Map<string, BaseRedisProvider>();
|
|
103
|
+
private static initPromises = new Map<string, Promise<BaseRedisProvider | null>>();
|
|
104
|
+
|
|
105
|
+
static async initialize(config: RedisConfig, prefix: string): Promise<BaseRedisProvider | null> {
|
|
106
|
+
const existing = this.instances.get(prefix);
|
|
107
|
+
if (existing) return existing;
|
|
108
|
+
|
|
109
|
+
const pendingPromise = this.initPromises.get(prefix);
|
|
110
|
+
if (pendingPromise) return pendingPromise;
|
|
111
|
+
|
|
112
|
+
const initPromise = (async () => {
|
|
113
|
+
const provider = createProvider(config, prefix);
|
|
114
|
+
if (!provider) return null;
|
|
115
|
+
|
|
116
|
+
await provider.initialize();
|
|
117
|
+
this.instances.set(prefix, provider);
|
|
118
|
+
return provider;
|
|
119
|
+
})().catch((error) => {
|
|
120
|
+
this.initPromises.delete(prefix);
|
|
121
|
+
throw error;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
this.initPromises.set(prefix, initPromise);
|
|
125
|
+
return initPromise;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
static async reset(prefix?: string) {
|
|
129
|
+
if (prefix) {
|
|
130
|
+
const instance = this.instances.get(prefix);
|
|
131
|
+
if (instance) {
|
|
132
|
+
await instance.disconnect();
|
|
133
|
+
this.instances.delete(prefix);
|
|
134
|
+
this.initPromises.delete(prefix);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
for (const instance of this.instances.values()) {
|
|
138
|
+
await instance.disconnect();
|
|
139
|
+
}
|
|
140
|
+
this.instances.clear();
|
|
141
|
+
this.initPromises.clear();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Initialize a singleton Redis client with custom prefix
|
|
148
|
+
*
|
|
149
|
+
* Unlike createRedisWithPrefix, this reuses the same client for each prefix,
|
|
150
|
+
* avoiding connection leaks when called frequently.
|
|
151
|
+
*
|
|
152
|
+
* @param config - Redis config
|
|
153
|
+
* @param prefix - Custom prefix for all keys (e.g., 'aiGeneration')
|
|
154
|
+
* @returns Redis client or null if Redis is disabled
|
|
155
|
+
*/
|
|
156
|
+
export const initializeRedisWithPrefix = (config: RedisConfig, prefix: string) =>
|
|
157
|
+
PrefixedRedisManager.initialize(config, prefix);
|
|
158
|
+
|
|
159
|
+
export const resetPrefixedRedisClient = (prefix?: string) => PrefixedRedisManager.reset(prefix);
|
|
@@ -66,7 +66,7 @@ const getVerificationKey = async () => {
|
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Sign JWT for internal lambda → async calls
|
|
69
|
-
* Uses JWKS private key with
|
|
69
|
+
* Uses JWKS private key with configurable expiration (default: 30s)
|
|
70
70
|
* The JWT only proves the request is from lambda, payload is sent via LOBE_CHAT_AUTH_HEADER
|
|
71
71
|
*/
|
|
72
72
|
export const signInternalJWT = async (): Promise<string> => {
|
|
@@ -75,7 +75,7 @@ export const signInternalJWT = async (): Promise<string> => {
|
|
|
75
75
|
return new SignJWT({ purpose: INTERNAL_JWT_PURPOSE })
|
|
76
76
|
.setProtectedHeader({ alg: 'RS256', kid })
|
|
77
77
|
.setIssuedAt()
|
|
78
|
-
.setExpirationTime(
|
|
78
|
+
.setExpirationTime(authEnv.INTERNAL_JWT_EXPIRATION)
|
|
79
79
|
.sign(key);
|
|
80
80
|
};
|
|
81
81
|
|
|
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
5
5
|
import { AgentModel } from '@/database/models/agent';
|
|
6
6
|
import { SessionModel } from '@/database/models/session';
|
|
7
7
|
import { UserModel } from '@/database/models/user';
|
|
8
|
-
import { RedisKeys,
|
|
8
|
+
import { RedisKeys, initializeRedisWithPrefix, isRedisEnabled } from '@/libs/redis';
|
|
9
9
|
import { parseAgentConfig } from '@/server/globalConfig/parseDefaultAgent';
|
|
10
10
|
|
|
11
11
|
import { AgentService } from './index';
|
|
@@ -43,7 +43,8 @@ vi.mock('@/libs/redis', async (importOriginal) => {
|
|
|
43
43
|
const original = await importOriginal<typeof import('@/libs/redis')>();
|
|
44
44
|
return {
|
|
45
45
|
...original,
|
|
46
|
-
|
|
46
|
+
initializeRedisWithPrefix: vi.fn(),
|
|
47
|
+
isRedisEnabled: vi.fn(),
|
|
47
48
|
};
|
|
48
49
|
});
|
|
49
50
|
|
|
@@ -290,7 +291,8 @@ describe('AgentService', () => {
|
|
|
290
291
|
const mockRedisClient = { get: mockRedisGet };
|
|
291
292
|
|
|
292
293
|
beforeEach(() => {
|
|
293
|
-
vi.mocked(
|
|
294
|
+
vi.mocked(initializeRedisWithPrefix).mockReset();
|
|
295
|
+
vi.mocked(isRedisEnabled).mockReset();
|
|
294
296
|
mockRedisGet.mockReset();
|
|
295
297
|
});
|
|
296
298
|
|
|
@@ -310,7 +312,8 @@ describe('AgentService', () => {
|
|
|
310
312
|
|
|
311
313
|
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
|
312
314
|
(parseAgentConfig as any).mockReturnValue({});
|
|
313
|
-
vi.mocked(
|
|
315
|
+
vi.mocked(isRedisEnabled).mockReturnValue(true);
|
|
316
|
+
vi.mocked(initializeRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
|
|
314
317
|
mockRedisGet.mockResolvedValue(JSON.stringify(welcomeData));
|
|
315
318
|
|
|
316
319
|
const newService = new AgentService(mockDb, mockUserId);
|
|
@@ -334,7 +337,7 @@ describe('AgentService', () => {
|
|
|
334
337
|
|
|
335
338
|
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
|
336
339
|
(parseAgentConfig as any).mockReturnValue({});
|
|
337
|
-
vi.mocked(
|
|
340
|
+
vi.mocked(isRedisEnabled).mockReturnValue(false);
|
|
338
341
|
|
|
339
342
|
const newService = new AgentService(mockDb, mockUserId);
|
|
340
343
|
const result = await newService.getAgentConfigById('agent-1');
|
|
@@ -343,6 +346,7 @@ describe('AgentService', () => {
|
|
|
343
346
|
expect(result?.openingMessage).toBe('Default message');
|
|
344
347
|
// openingQuestions comes from DEFAULT_AGENT_CONFIG (empty array)
|
|
345
348
|
expect(result?.openingQuestions).toEqual([]);
|
|
349
|
+
expect(initializeRedisWithPrefix).not.toHaveBeenCalled();
|
|
346
350
|
});
|
|
347
351
|
|
|
348
352
|
it('should return normal config when Redis key does not exist', async () => {
|
|
@@ -357,7 +361,8 @@ describe('AgentService', () => {
|
|
|
357
361
|
|
|
358
362
|
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
|
359
363
|
(parseAgentConfig as any).mockReturnValue({});
|
|
360
|
-
vi.mocked(
|
|
364
|
+
vi.mocked(isRedisEnabled).mockReturnValue(true);
|
|
365
|
+
vi.mocked(initializeRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
|
|
361
366
|
mockRedisGet.mockResolvedValue(null);
|
|
362
367
|
|
|
363
368
|
const newService = new AgentService(mockDb, mockUserId);
|
|
@@ -381,7 +386,8 @@ describe('AgentService', () => {
|
|
|
381
386
|
|
|
382
387
|
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
|
383
388
|
(parseAgentConfig as any).mockReturnValue({});
|
|
384
|
-
vi.mocked(
|
|
389
|
+
vi.mocked(isRedisEnabled).mockReturnValue(true);
|
|
390
|
+
vi.mocked(initializeRedisWithPrefix).mockRejectedValue(new Error('Redis connection failed'));
|
|
385
391
|
|
|
386
392
|
const newService = new AgentService(mockDb, mockUserId);
|
|
387
393
|
const result = await newService.getAgentConfigById('agent-1');
|
|
@@ -403,7 +409,8 @@ describe('AgentService', () => {
|
|
|
403
409
|
|
|
404
410
|
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
|
405
411
|
(parseAgentConfig as any).mockReturnValue({});
|
|
406
|
-
vi.mocked(
|
|
412
|
+
vi.mocked(isRedisEnabled).mockReturnValue(true);
|
|
413
|
+
vi.mocked(initializeRedisWithPrefix).mockResolvedValue(mockRedisClient as any);
|
|
407
414
|
mockRedisGet.mockResolvedValue('invalid json {');
|
|
408
415
|
|
|
409
416
|
const newService = new AgentService(mockDb, mockUserId);
|