@lobehub/chat 1.106.0 → 1.106.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.106.2](https://github.com/lobehub/lobe-chat/compare/v1.106.1...v1.106.2)
6
+
7
+ <sup>Released on **2025-07-29**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **misc**: Fix desktop auth redirect url error.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's fixed
19
+
20
+ - **misc**: Fix desktop auth redirect url error, closes [#8597](https://github.com/lobehub/lobe-chat/issues/8597) ([0ed7368](https://github.com/lobehub/lobe-chat/commit/0ed7368))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ### [Version 1.106.1](https://github.com/lobehub/lobe-chat/compare/v1.106.0...v1.106.1)
31
+
32
+ <sup>Released on **2025-07-29**</sup>
33
+
34
+ #### 💄 Styles
35
+
36
+ - **misc**: Support Minimax T2I models.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### Styles
44
+
45
+ - **misc**: Support Minimax T2I models, closes [#8583](https://github.com/lobehub/lobe-chat/issues/8583) ([f8a01aa](https://github.com/lobehub/lobe-chat/commit/f8a01aa))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ## [Version 1.106.0](https://github.com/lobehub/lobe-chat/compare/v1.105.6...v1.106.0)
6
56
 
7
57
  <sup>Released on **2025-07-29**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "fixes": [
5
+ "Fix desktop auth redirect url error."
6
+ ]
7
+ },
8
+ "date": "2025-07-29",
9
+ "version": "1.106.2"
10
+ },
11
+ {
12
+ "children": {
13
+ "improvements": [
14
+ "Support Minimax T2I models."
15
+ ]
16
+ },
17
+ "date": "2025-07-29",
18
+ "version": "1.106.1"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "features": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.106.0",
3
+ "version": "1.106.2",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -257,7 +257,7 @@
257
257
  "semver": "^7.7.2",
258
258
  "sharp": "^0.34.3",
259
259
  "shiki": "^3.8.1",
260
- "stripe": "^16.12.0",
260
+ "stripe": "^17.7.0",
261
261
  "superjson": "^2.2.2",
262
262
  "svix": "^1.69.0",
263
263
  "swr": "^2.3.4",
@@ -3,9 +3,50 @@ import { NextRequest, NextResponse, after } from 'next/server';
3
3
 
4
4
  import { OAuthHandoffModel } from '@/database/models/oauthHandoff';
5
5
  import { serverDB } from '@/database/server';
6
+ import { correctOIDCUrl } from '@/utils/server/correctOIDCUrl';
6
7
 
7
8
  const log = debug('lobe-oidc:callback:desktop');
8
9
 
10
+ const errorPathname = '/oauth/callback/error';
11
+
12
+ /**
13
+ * 安全地构建重定向URL
14
+ */
15
+ const buildRedirectUrl = (req: NextRequest, pathname: string): URL => {
16
+ const forwardedHost = req.headers.get('x-forwarded-host');
17
+ const requestHost = req.headers.get('host');
18
+ const forwardedProto =
19
+ req.headers.get('x-forwarded-proto') || req.headers.get('x-forwarded-protocol');
20
+
21
+ // 确定实际的主机名,提供后备值
22
+ const actualHost = forwardedHost || requestHost;
23
+ const actualProto = forwardedProto || 'https';
24
+
25
+ log(
26
+ 'Building redirect URL - host: %s, proto: %s, pathname: %s',
27
+ actualHost,
28
+ actualProto,
29
+ pathname,
30
+ );
31
+
32
+ // 如果主机名仍然无效,使用req.nextUrl作为后备
33
+ if (!actualHost) {
34
+ log('Warning: Invalid host detected, using req.nextUrl as fallback');
35
+ const fallbackUrl = req.nextUrl.clone();
36
+ fallbackUrl.pathname = pathname;
37
+ return fallbackUrl;
38
+ }
39
+
40
+ try {
41
+ return new URL(`${actualProto}://${actualHost}${pathname}`);
42
+ } catch (error) {
43
+ log('Error constructing URL, using req.nextUrl as fallback: %O', error);
44
+ const fallbackUrl = req.nextUrl.clone();
45
+ fallbackUrl.pathname = pathname;
46
+ return fallbackUrl;
47
+ }
48
+ };
49
+
9
50
  export const GET = async (req: NextRequest) => {
10
51
  try {
11
52
  const searchParams = req.nextUrl.searchParams;
@@ -14,9 +55,11 @@ export const GET = async (req: NextRequest) => {
14
55
 
15
56
  if (!code || !state || typeof code !== 'string' || typeof state !== 'string') {
16
57
  log('Missing code or state in form data');
17
- const errorUrl = req.nextUrl.clone();
18
- errorUrl.pathname = '/oauth/callback/error';
58
+
59
+ const errorUrl = buildRedirectUrl(req, errorPathname);
19
60
  errorUrl.searchParams.set('reason', 'invalid_request');
61
+
62
+ log('Redirecting to error URL: %s', errorUrl.toString());
20
63
  return NextResponse.redirect(errorUrl);
21
64
  }
22
65
 
@@ -31,9 +74,16 @@ export const GET = async (req: NextRequest) => {
31
74
  await authHandoffModel.create({ client, id, payload });
32
75
  log('Handoff record created successfully for id: %s', id);
33
76
 
34
- // Redirect to a generic success page. The desktop app will poll for the result.
35
- const successUrl = req.nextUrl.clone();
36
- successUrl.pathname = '/oauth/callback/success';
77
+ const successUrl = buildRedirectUrl(req, '/oauth/callback/success');
78
+
79
+ // 添加调试日志
80
+ log('Request host header: %s', req.headers.get('host'));
81
+ log('Request x-forwarded-host: %s', req.headers.get('x-forwarded-host'));
82
+ log('Request x-forwarded-proto: %s', req.headers.get('x-forwarded-proto'));
83
+ log('Constructed success URL: %s', successUrl.toString());
84
+
85
+ const correctedUrl = correctOIDCUrl(req, successUrl);
86
+ log('Final redirect URL: %s', correctedUrl.toString());
37
87
 
38
88
  // cleanup expired
39
89
  after(async () => {
@@ -42,17 +92,18 @@ export const GET = async (req: NextRequest) => {
42
92
  log('Cleaned up %d expired handoff records', cleanedCount);
43
93
  });
44
94
 
45
- return NextResponse.redirect(successUrl);
95
+ return NextResponse.redirect(correctedUrl);
46
96
  } catch (error) {
47
97
  log('Error in OIDC callback: %O', error);
48
- const errorUrl = req.nextUrl.clone();
49
- errorUrl.pathname = '/oauth/callback/error';
98
+
99
+ const errorUrl = buildRedirectUrl(req, errorPathname);
50
100
  errorUrl.searchParams.set('reason', 'internal_error');
51
101
 
52
102
  if (error instanceof Error) {
53
103
  errorUrl.searchParams.set('errorMessage', error.message);
54
104
  }
55
105
 
106
+ log('Redirecting to error URL: %s', errorUrl.toString());
56
107
  return NextResponse.redirect(errorUrl);
57
108
  }
58
109
  };
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
3
3
 
4
4
  import { OIDCService } from '@/server/services/oidc';
5
5
  import { getUserAuth } from '@/utils/server/auth';
6
+ import { correctOIDCUrl } from '@/utils/server/correctOIDCUrl';
6
7
 
7
8
  const log = debug('lobe-oidc:consent');
8
9
 
@@ -113,19 +114,15 @@ export async function POST(request: NextRequest) {
113
114
  const internalRedirectUrlString = await oidcService.getInteractionResult(uid, result);
114
115
  log('OIDC Provider internal redirect URL string: %s', internalRedirectUrlString);
115
116
 
116
- // // Construct the handoff URL
117
- // const handoffUrl = new URL('/oauth/handoff', request.nextUrl.origin);
118
- // // Set the original redirect URL as the 'target' query parameter (URL encoded)
119
- // handoffUrl.searchParams.set('target', internalRedirectUrlString);
120
- //
121
- // log('Redirecting to handoff page: %s', handoffUrl.toString());
122
- // // Redirect to the handoff page
123
- // return NextResponse.redirect(handoffUrl.toString(), {
124
- // headers: request.headers, // Keep original headers if necessary
125
- // status: 303,
126
- // });
127
-
128
- return NextResponse.redirect(internalRedirectUrlString, {
117
+ let finalRedirectUrl;
118
+ try {
119
+ finalRedirectUrl = correctOIDCUrl(request, new URL(internalRedirectUrlString));
120
+ } catch {
121
+ finalRedirectUrl = new URL(internalRedirectUrlString);
122
+ log('Warning: Could not parse redirect URL, using as-is: %s', internalRedirectUrlString);
123
+ }
124
+
125
+ return NextResponse.redirect(finalRedirectUrl, {
129
126
  headers: request.headers,
130
127
  status: 303,
131
128
  });
@@ -5,15 +5,18 @@ import { useSize } from 'ahooks';
5
5
  import { memo, useRef } from 'react';
6
6
  import { Flexbox } from 'react-layout-kit';
7
7
 
8
- import { useFetchGenerationTopics } from '@/hooks/useFetchGenerationTopics';
9
8
  import { useImageStore } from '@/store/image';
10
9
  import { generationTopicSelectors } from '@/store/image/selectors';
10
+ import { useUserStore } from '@/store/user';
11
+ import { authSelectors } from '@/store/user/slices/auth/selectors';
11
12
 
12
13
  import NewTopicButton from './NewTopicButton';
13
14
  import TopicItem from './TopicItem';
14
15
 
15
16
  const TopicsList = memo(() => {
16
- useFetchGenerationTopics();
17
+ const isLogin = useUserStore(authSelectors.isLogin);
18
+ const useFetchGenerationTopics = useImageStore((s) => s.useFetchGenerationTopics);
19
+ useFetchGenerationTopics(!!isLogin);
17
20
  const ref = useRef(null);
18
21
  const { width = 80 } = useSize(ref) || {};
19
22
  const [parent] = useAutoAnimate();
@@ -7,9 +7,12 @@ import type { KeyboardEvent } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
8
8
  import { Flexbox } from 'react-layout-kit';
9
9
 
10
+ import { loginRequired } from '@/components/Error/loginRequiredNotification';
10
11
  import { useImageStore } from '@/store/image';
11
12
  import { createImageSelectors } from '@/store/image/selectors';
12
13
  import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
14
+ import { useUserStore } from '@/store/user';
15
+ import { authSelectors } from '@/store/user/slices/auth/selectors';
13
16
 
14
17
  import PromptTitle from './Title';
15
18
 
@@ -46,8 +49,14 @@ const PromptInput = ({ showTitle = false }: PromptInputProps) => {
46
49
  const { value, setValue } = useGenerationConfigParam('prompt');
47
50
  const isCreating = useImageStore(createImageSelectors.isCreating);
48
51
  const createImage = useImageStore((s) => s.createImage);
52
+ const isLogin = useUserStore(authSelectors.isLogin);
49
53
 
50
54
  const handleGenerate = async () => {
55
+ if (!isLogin) {
56
+ loginRequired.redirect({ timeout: 2000 });
57
+ return;
58
+ }
59
+
51
60
  await createImage();
52
61
  };
53
62
 
@@ -1,4 +1,4 @@
1
- import { AIChatModelCard } from '@/types/aiModel';
1
+ import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
2
2
 
3
3
  const minimaxChatModels: AIChatModelCard[] = [
4
4
  {
@@ -51,6 +51,47 @@ const minimaxChatModels: AIChatModelCard[] = [
51
51
  },
52
52
  ];
53
53
 
54
- export const allModels = [...minimaxChatModels];
54
+ const minimaxImageModels: AIImageModelCard[] = [
55
+ {
56
+ description:
57
+ '全新图像生成模型,画面表现细腻,支持文生图、图生图',
58
+ displayName: 'Image 01',
59
+ enabled: true,
60
+ id: 'image-01',
61
+ parameters: {
62
+ aspectRatio: {
63
+ default: '1:1',
64
+ enum: ['1:1', '16:9', '4:3', '3:2', '2:3', '3:4', '9:16', '21:9'],
65
+ },
66
+ prompt: {
67
+ default: '',
68
+ },
69
+ seed: { default: null },
70
+ },
71
+ releasedAt: '2025-02-28',
72
+ type: 'image',
73
+ },
74
+ {
75
+ description:
76
+ '图像生成模型,画面表现细腻,支持文生图并进行画风设置',
77
+ displayName: 'Image 01 Live',
78
+ enabled: true,
79
+ id: 'image-01-live',
80
+ parameters: {
81
+ aspectRatio: {
82
+ default: '1:1',
83
+ enum: ['1:1', '16:9', '4:3', '3:2', '2:3', '3:4', '9:16', '21:9'],
84
+ },
85
+ prompt: {
86
+ default: '',
87
+ },
88
+ seed: { default: null },
89
+ },
90
+ releasedAt: '2025-02-28',
91
+ type: 'image',
92
+ },
93
+ ];
94
+
95
+ export const allModels = [...minimaxChatModels, ...minimaxImageModels];
55
96
 
56
97
  export default allModels;
@@ -0,0 +1,553 @@
1
+ // @vitest-environment edge-runtime
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { CreateImagePayload } from '../types/image';
5
+ import { CreateImageOptions } from '../utils/openaiCompatibleFactory';
6
+ import { createMiniMaxImage } from './createImage';
7
+
8
+ // Mock the console.error to avoid polluting test output
9
+ vi.spyOn(console, 'error').mockImplementation(() => {});
10
+
11
+ const mockOptions: CreateImageOptions = {
12
+ apiKey: 'test-api-key',
13
+ baseURL: 'https://api.minimaxi.com/v1',
14
+ provider: 'minimax',
15
+ };
16
+
17
+ beforeEach(() => {
18
+ // Reset all mocks before each test
19
+ vi.clearAllMocks();
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ describe('createMiniMaxImage', () => {
27
+ describe('Success scenarios', () => {
28
+ it('should successfully generate image with basic prompt', async () => {
29
+ const mockImageUrl = 'https://minimax-cdn.com/images/generated/test-image.jpg';
30
+
31
+ global.fetch = vi.fn().mockResolvedValueOnce({
32
+ ok: true,
33
+ json: async () => ({
34
+ base_resp: {
35
+ status_code: 0,
36
+ status_msg: 'success',
37
+ },
38
+ data: {
39
+ image_urls: [mockImageUrl],
40
+ },
41
+ id: 'img-123456',
42
+ metadata: {
43
+ failed_count: '0',
44
+ success_count: '1',
45
+ },
46
+ }),
47
+ });
48
+
49
+ const payload: CreateImagePayload = {
50
+ model: 'image-01',
51
+ params: {
52
+ prompt: 'A beautiful sunset over the mountains',
53
+ },
54
+ };
55
+
56
+ const result = await createMiniMaxImage(payload, mockOptions);
57
+
58
+ expect(fetch).toHaveBeenCalledWith(
59
+ 'https://api.minimaxi.com/v1/image_generation',
60
+ {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Authorization': 'Bearer test-api-key',
64
+ 'Content-Type': 'application/json',
65
+ },
66
+ body: JSON.stringify({
67
+ aspect_ratio: undefined,
68
+ model: 'image-01',
69
+ n: 1,
70
+ prompt: 'A beautiful sunset over the mountains',
71
+ }),
72
+ },
73
+ );
74
+
75
+ expect(result).toEqual({
76
+ imageUrl: mockImageUrl,
77
+ });
78
+ });
79
+
80
+ it('should handle custom aspect ratio', async () => {
81
+ const mockImageUrl = 'https://minimax-cdn.com/images/generated/custom-ratio.jpg';
82
+
83
+ global.fetch = vi.fn().mockResolvedValueOnce({
84
+ ok: true,
85
+ json: async () => ({
86
+ base_resp: {
87
+ status_code: 0,
88
+ status_msg: 'success',
89
+ },
90
+ data: {
91
+ image_urls: [mockImageUrl],
92
+ },
93
+ id: 'img-custom-ratio',
94
+ metadata: {
95
+ failed_count: '0',
96
+ success_count: '1',
97
+ },
98
+ }),
99
+ });
100
+
101
+ const payload: CreateImagePayload = {
102
+ model: 'image-01',
103
+ params: {
104
+ prompt: 'Abstract digital art',
105
+ aspectRatio: '16:9',
106
+ },
107
+ };
108
+
109
+ const result = await createMiniMaxImage(payload, mockOptions);
110
+
111
+ expect(fetch).toHaveBeenCalledWith(
112
+ 'https://api.minimaxi.com/v1/image_generation',
113
+ expect.objectContaining({
114
+ body: JSON.stringify({
115
+ aspect_ratio: '16:9',
116
+ model: 'image-01',
117
+ n: 1,
118
+ prompt: 'Abstract digital art',
119
+ }),
120
+ }),
121
+ );
122
+
123
+ expect(result).toEqual({
124
+ imageUrl: mockImageUrl,
125
+ });
126
+ });
127
+
128
+ it('should handle seed value correctly', async () => {
129
+ const mockImageUrl = 'https://minimax-cdn.com/images/generated/seeded-image.jpg';
130
+
131
+ global.fetch = vi.fn().mockResolvedValueOnce({
132
+ ok: true,
133
+ json: async () => ({
134
+ base_resp: {
135
+ status_code: 0,
136
+ status_msg: 'success',
137
+ },
138
+ data: {
139
+ image_urls: [mockImageUrl],
140
+ },
141
+ id: 'img-seeded',
142
+ metadata: {
143
+ failed_count: '0',
144
+ success_count: '1',
145
+ },
146
+ }),
147
+ });
148
+
149
+ const payload: CreateImagePayload = {
150
+ model: 'image-01',
151
+ params: {
152
+ prompt: 'Reproducible image with seed',
153
+ seed: 42,
154
+ },
155
+ };
156
+
157
+ const result = await createMiniMaxImage(payload, mockOptions);
158
+
159
+ expect(fetch).toHaveBeenCalledWith(
160
+ 'https://api.minimaxi.com/v1/image_generation',
161
+ expect.objectContaining({
162
+ body: JSON.stringify({
163
+ aspect_ratio: undefined,
164
+ model: 'image-01',
165
+ n: 1,
166
+ prompt: 'Reproducible image with seed',
167
+ seed: 42,
168
+ }),
169
+ }),
170
+ );
171
+
172
+ expect(result).toEqual({
173
+ imageUrl: mockImageUrl,
174
+ });
175
+ });
176
+
177
+ it('should handle seed value of 0 correctly', async () => {
178
+ const mockImageUrl = 'https://minimax-cdn.com/images/generated/zero-seed.jpg';
179
+
180
+ global.fetch = vi.fn().mockResolvedValueOnce({
181
+ ok: true,
182
+ json: async () => ({
183
+ base_resp: {
184
+ status_code: 0,
185
+ status_msg: 'success',
186
+ },
187
+ data: {
188
+ image_urls: [mockImageUrl],
189
+ },
190
+ id: 'img-zero-seed',
191
+ metadata: {
192
+ failed_count: '0',
193
+ success_count: '1',
194
+ },
195
+ }),
196
+ });
197
+
198
+ const payload: CreateImagePayload = {
199
+ model: 'image-01',
200
+ params: {
201
+ prompt: 'Image with seed 0',
202
+ seed: 0,
203
+ },
204
+ };
205
+
206
+ await createMiniMaxImage(payload, mockOptions);
207
+
208
+ // Verify that seed: 0 is included in the request
209
+ expect(fetch).toHaveBeenCalledWith(
210
+ 'https://api.minimaxi.com/v1/image_generation',
211
+ expect.objectContaining({
212
+ body: JSON.stringify({
213
+ aspect_ratio: undefined,
214
+ model: 'image-01',
215
+ n: 1,
216
+ prompt: 'Image with seed 0',
217
+ seed: 0,
218
+ }),
219
+ }),
220
+ );
221
+ });
222
+
223
+ it('should handle multiple generated images and return the first one', async () => {
224
+ const mockImageUrls = [
225
+ 'https://minimax-cdn.com/images/generated/image-1.jpg',
226
+ 'https://minimax-cdn.com/images/generated/image-2.jpg',
227
+ ];
228
+
229
+ global.fetch = vi.fn().mockResolvedValueOnce({
230
+ ok: true,
231
+ json: async () => ({
232
+ base_resp: {
233
+ status_code: 0,
234
+ status_msg: 'success',
235
+ },
236
+ data: {
237
+ image_urls: mockImageUrls,
238
+ },
239
+ id: 'img-multiple',
240
+ metadata: {
241
+ failed_count: '0',
242
+ success_count: '2',
243
+ },
244
+ }),
245
+ });
246
+
247
+ const payload: CreateImagePayload = {
248
+ model: 'image-01',
249
+ params: {
250
+ prompt: 'Multiple images test',
251
+ },
252
+ };
253
+
254
+ const result = await createMiniMaxImage(payload, mockOptions);
255
+
256
+ expect(result).toEqual({
257
+ imageUrl: mockImageUrls[0], // Should return the first image
258
+ });
259
+ });
260
+
261
+ it('should handle partial failures gracefully', async () => {
262
+ const mockImageUrl = 'https://minimax-cdn.com/images/generated/partial-success.jpg';
263
+
264
+ global.fetch = vi.fn().mockResolvedValueOnce({
265
+ ok: true,
266
+ json: async () => ({
267
+ base_resp: {
268
+ status_code: 0,
269
+ status_msg: 'success',
270
+ },
271
+ data: {
272
+ image_urls: [mockImageUrl],
273
+ },
274
+ id: 'img-partial',
275
+ metadata: {
276
+ failed_count: '2',
277
+ success_count: '1',
278
+ },
279
+ }),
280
+ });
281
+
282
+ const payload: CreateImagePayload = {
283
+ model: 'image-01',
284
+ params: {
285
+ prompt: 'Test partial failure',
286
+ },
287
+ };
288
+
289
+ const result = await createMiniMaxImage(payload, mockOptions);
290
+
291
+ expect(result).toEqual({
292
+ imageUrl: mockImageUrl,
293
+ });
294
+ });
295
+ });
296
+
297
+ describe('Error scenarios', () => {
298
+ it('should handle HTTP error responses', async () => {
299
+ global.fetch = vi.fn().mockResolvedValueOnce({
300
+ ok: false,
301
+ status: 400,
302
+ statusText: 'Bad Request',
303
+ json: async () => ({
304
+ base_resp: {
305
+ status_code: 1001,
306
+ status_msg: 'Invalid prompt format',
307
+ },
308
+ }),
309
+ });
310
+
311
+ const payload: CreateImagePayload = {
312
+ model: 'image-01',
313
+ params: {
314
+ prompt: 'Invalid prompt',
315
+ },
316
+ };
317
+
318
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
319
+ expect.objectContaining({
320
+ errorType: 'ProviderBizError',
321
+ provider: 'minimax',
322
+ }),
323
+ );
324
+ });
325
+
326
+ it('should handle non-JSON error responses', async () => {
327
+ global.fetch = vi.fn().mockResolvedValueOnce({
328
+ ok: false,
329
+ status: 500,
330
+ statusText: 'Internal Server Error',
331
+ json: async () => {
332
+ throw new Error('Failed to parse JSON');
333
+ },
334
+ });
335
+
336
+ const payload: CreateImagePayload = {
337
+ model: 'image-01',
338
+ params: {
339
+ prompt: 'Test prompt',
340
+ },
341
+ };
342
+
343
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
344
+ expect.objectContaining({
345
+ errorType: 'ProviderBizError',
346
+ provider: 'minimax',
347
+ }),
348
+ );
349
+ });
350
+
351
+ it('should handle API error status codes', async () => {
352
+ global.fetch = vi.fn().mockResolvedValueOnce({
353
+ ok: true,
354
+ json: async () => ({
355
+ base_resp: {
356
+ status_code: 1002,
357
+ status_msg: 'Content policy violation',
358
+ },
359
+ data: {
360
+ image_urls: [],
361
+ },
362
+ id: 'img-error',
363
+ metadata: {
364
+ failed_count: '1',
365
+ success_count: '0',
366
+ },
367
+ }),
368
+ });
369
+
370
+ const payload: CreateImagePayload = {
371
+ model: 'image-01',
372
+ params: {
373
+ prompt: 'Inappropriate content',
374
+ },
375
+ };
376
+
377
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
378
+ expect.objectContaining({
379
+ errorType: 'ProviderBizError',
380
+ provider: 'minimax',
381
+ }),
382
+ );
383
+ });
384
+
385
+ it('should handle empty image URLs array', async () => {
386
+ global.fetch = vi.fn().mockResolvedValueOnce({
387
+ ok: true,
388
+ json: async () => ({
389
+ base_resp: {
390
+ status_code: 0,
391
+ status_msg: 'success',
392
+ },
393
+ data: {
394
+ image_urls: [],
395
+ },
396
+ id: 'img-empty',
397
+ metadata: {
398
+ failed_count: '1',
399
+ success_count: '0',
400
+ },
401
+ }),
402
+ });
403
+
404
+ const payload: CreateImagePayload = {
405
+ model: 'image-01',
406
+ params: {
407
+ prompt: 'Empty result test',
408
+ },
409
+ };
410
+
411
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
412
+ expect.objectContaining({
413
+ errorType: 'ProviderBizError',
414
+ provider: 'minimax',
415
+ }),
416
+ );
417
+ });
418
+
419
+ it('should handle missing data field', async () => {
420
+ global.fetch = vi.fn().mockResolvedValueOnce({
421
+ ok: true,
422
+ json: async () => ({
423
+ base_resp: {
424
+ status_code: 0,
425
+ status_msg: 'success',
426
+ },
427
+ id: 'img-no-data',
428
+ metadata: {
429
+ failed_count: '0',
430
+ success_count: '1',
431
+ },
432
+ }),
433
+ });
434
+
435
+ const payload: CreateImagePayload = {
436
+ model: 'image-01',
437
+ params: {
438
+ prompt: 'Missing data test',
439
+ },
440
+ };
441
+
442
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
443
+ expect.objectContaining({
444
+ errorType: 'ProviderBizError',
445
+ provider: 'minimax',
446
+ }),
447
+ );
448
+ });
449
+
450
+ it('should handle null/empty image URL', async () => {
451
+ global.fetch = vi.fn().mockResolvedValueOnce({
452
+ ok: true,
453
+ json: async () => ({
454
+ base_resp: {
455
+ status_code: 0,
456
+ status_msg: 'success',
457
+ },
458
+ data: {
459
+ image_urls: [''], // Empty string URL
460
+ },
461
+ id: 'img-empty-url',
462
+ metadata: {
463
+ failed_count: '0',
464
+ success_count: '1',
465
+ },
466
+ }),
467
+ });
468
+
469
+ const payload: CreateImagePayload = {
470
+ model: 'image-01',
471
+ params: {
472
+ prompt: 'Empty URL test',
473
+ },
474
+ };
475
+
476
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
477
+ expect.objectContaining({
478
+ errorType: 'ProviderBizError',
479
+ provider: 'minimax',
480
+ }),
481
+ );
482
+ });
483
+
484
+ it('should handle network errors', async () => {
485
+ global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network connection failed'));
486
+
487
+ const payload: CreateImagePayload = {
488
+ model: 'image-01',
489
+ params: {
490
+ prompt: 'Network error test',
491
+ },
492
+ };
493
+
494
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
495
+ expect.objectContaining({
496
+ errorType: 'ProviderBizError',
497
+ provider: 'minimax',
498
+ }),
499
+ );
500
+ });
501
+
502
+ it('should handle unauthorized access', async () => {
503
+ global.fetch = vi.fn().mockResolvedValueOnce({
504
+ ok: false,
505
+ status: 401,
506
+ statusText: 'Unauthorized',
507
+ json: async () => ({
508
+ base_resp: {
509
+ status_code: 1003,
510
+ status_msg: 'Invalid API key',
511
+ },
512
+ }),
513
+ });
514
+
515
+ const payload: CreateImagePayload = {
516
+ model: 'image-01',
517
+ params: {
518
+ prompt: 'Unauthorized test',
519
+ },
520
+ };
521
+
522
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
523
+ expect.objectContaining({
524
+ errorType: 'ProviderBizError',
525
+ provider: 'minimax',
526
+ }),
527
+ );
528
+ });
529
+
530
+ it('should handle malformed JSON response', async () => {
531
+ global.fetch = vi.fn().mockResolvedValueOnce({
532
+ ok: true,
533
+ json: async () => {
534
+ throw new Error('Unexpected token in JSON');
535
+ },
536
+ });
537
+
538
+ const payload: CreateImagePayload = {
539
+ model: 'image-01',
540
+ params: {
541
+ prompt: 'JSON error test',
542
+ },
543
+ };
544
+
545
+ await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
546
+ expect.objectContaining({
547
+ errorType: 'ProviderBizError',
548
+ provider: 'minimax',
549
+ }),
550
+ );
551
+ });
552
+ });
553
+ });
@@ -0,0 +1,105 @@
1
+ import createDebug from 'debug';
2
+
3
+ import { CreateImagePayload, CreateImageResponse } from '../types/image';
4
+ import { AgentRuntimeError } from '../utils/createError';
5
+ import { CreateImageOptions } from '../utils/openaiCompatibleFactory';
6
+
7
+ const log = createDebug('lobe-image:minimax');
8
+
9
+ interface MiniMaxImageResponse {
10
+ base_resp: {
11
+ status_code: number;
12
+ status_msg: string;
13
+ };
14
+ data: {
15
+ image_urls: string[];
16
+ };
17
+ id: string;
18
+ metadata: {
19
+ failed_count: string;
20
+ success_count: string;
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Create image using MiniMax API
26
+ */
27
+ export async function createMiniMaxImage(
28
+ payload: CreateImagePayload,
29
+ options: CreateImageOptions,
30
+ ): Promise<CreateImageResponse> {
31
+ const { apiKey, baseURL, provider } = options;
32
+ const { model, params } = payload;
33
+
34
+ try {
35
+ const endpoint = `${baseURL}/image_generation`;
36
+
37
+ const response = await fetch(endpoint, {
38
+ body: JSON.stringify({
39
+ aspect_ratio: params.aspectRatio,
40
+ model,
41
+ n: 1,
42
+ prompt: params.prompt,
43
+ //prompt_optimizer: true, // 开启 prompt 自动优化
44
+ ...(typeof params.seed === 'number' ? { seed: params.seed } : {}),
45
+ }),
46
+ headers: {
47
+ 'Authorization': `Bearer ${apiKey}`,
48
+ 'Content-Type': 'application/json',
49
+ },
50
+ method: 'POST',
51
+ });
52
+
53
+ if (!response.ok) {
54
+ let errorData;
55
+ try {
56
+ errorData = await response.json();
57
+ } catch {
58
+ // Failed to parse JSON error response
59
+ }
60
+
61
+ throw new Error(
62
+ `MiniMax API error (${response.status}): ${errorData?.base_resp || response.statusText}`
63
+ );
64
+ }
65
+
66
+ const data: MiniMaxImageResponse = await response.json();
67
+
68
+ log('Image generation response: %O', data);
69
+
70
+ // Check API response status
71
+ if (data.base_resp.status_code !== 0) {
72
+ throw new Error(`MiniMax API error: ${data.base_resp.status_msg}`);
73
+ }
74
+
75
+ // Check if we have valid image data
76
+ if (!data.data?.image_urls || data.data.image_urls.length === 0) {
77
+ throw new Error('No images generated in response');
78
+ }
79
+
80
+ // Log generation statistics
81
+ const successCount = parseInt(data.metadata.success_count);
82
+ const failedCount = parseInt(data.metadata.failed_count);
83
+ log('Image generation completed: %d successful, %d failed', successCount, failedCount);
84
+
85
+ // Return the first generated image URL
86
+ const imageUrl = data.data.image_urls[0];
87
+
88
+ if (!imageUrl) {
89
+ throw new Error('No valid image URL in response');
90
+ }
91
+
92
+ log('Image generated successfully: %s', imageUrl);
93
+
94
+ return { imageUrl };
95
+
96
+ } catch (error) {
97
+ log('Error in createMiniMaxImage: %O', error);
98
+
99
+ throw AgentRuntimeError.createImage({
100
+ error: error as any,
101
+ errorType: 'ProviderBizError',
102
+ provider,
103
+ });
104
+ }
105
+ }
@@ -2,6 +2,7 @@ import minimaxChatModels from '@/config/aiModels/minimax';
2
2
 
3
3
  import { ModelProvider } from '../types';
4
4
  import { createOpenAICompatibleRuntime } from '../utils/openaiCompatibleFactory';
5
+ import { createMiniMaxImage } from './createImage';
5
6
 
6
7
  export const getMinimaxMaxOutputs = (modelId: string): number | undefined => {
7
8
  const model = minimaxChatModels.find((model) => model.id === modelId);
@@ -34,6 +35,7 @@ export const LobeMinimaxAI = createOpenAICompatibleRuntime({
34
35
  } as any;
35
36
  },
36
37
  },
38
+ createImage: createMiniMaxImage,
37
39
  debug: {
38
40
  chatCompletion: () => process.env.DEBUG_MINIMAX_CHAT_COMPLETION === '1',
39
41
  },
@@ -405,7 +405,7 @@ describe('GenerationTopicAction', () => {
405
405
  const { result } = renderHook(() => {
406
406
  const store = useImageStore();
407
407
  // Actually call the SWR hook to trigger the service call
408
- const swrResult = store.useFetchGenerationTopics(true, true);
408
+ const swrResult = store.useFetchGenerationTopics(true);
409
409
 
410
410
  // Simulate the SWR onSuccess callback behavior
411
411
  React.useEffect(() => {
@@ -426,7 +426,7 @@ describe('GenerationTopicAction', () => {
426
426
  });
427
427
 
428
428
  it('should not fetch when disabled', async () => {
429
- const { result } = renderHook(() => useImageStore().useFetchGenerationTopics(false, true));
429
+ const { result } = renderHook(() => useImageStore().useFetchGenerationTopics(false));
430
430
 
431
431
  expect(result.current.data).toBeUndefined();
432
432
  expect(generationTopicService.getAllGenerationTopics).not.toHaveBeenCalled();
@@ -455,7 +455,7 @@ describe('GenerationTopicAction', () => {
455
455
  await result.current.refreshGenerationTopics();
456
456
  });
457
457
 
458
- expect(mutate).toHaveBeenCalledWith(['fetchGenerationTopics', true]);
458
+ expect(mutate).toHaveBeenCalledWith(['fetchGenerationTopics']);
459
459
  });
460
460
  });
461
461
 
@@ -25,10 +25,7 @@ const n = setNamespace('generationTopic');
25
25
  export interface GenerationTopicAction {
26
26
  createGenerationTopic: (prompts: string[]) => Promise<string>;
27
27
  removeGenerationTopic: (id: string) => Promise<void>;
28
- useFetchGenerationTopics: (
29
- enabled: boolean,
30
- isLogin: boolean | undefined,
31
- ) => SWRResponse<ImageGenerationTopic[]>;
28
+ useFetchGenerationTopics: (enabled: boolean) => SWRResponse<ImageGenerationTopic[]>;
32
29
  summaryGenerationTopicTitle: (topicId: string, prompts: string[]) => Promise<string>;
33
30
  refreshGenerationTopics: () => Promise<void>;
34
31
  switchGenerationTopic: (topicId: string) => void;
@@ -216,9 +213,9 @@ export const createGenerationTopicSlice: StateCreator<
216
213
  );
217
214
  },
218
215
 
219
- useFetchGenerationTopics: (enabled, isLogin) =>
216
+ useFetchGenerationTopics: (enabled) =>
220
217
  useClientDataSWR<ImageGenerationTopic[]>(
221
- enabled ? [FETCH_GENERATION_TOPICS_KEY, isLogin] : null,
218
+ enabled ? [FETCH_GENERATION_TOPICS_KEY] : null,
222
219
  () => generationTopicService.getAllGenerationTopics(),
223
220
  {
224
221
  suspense: true,
@@ -231,7 +228,7 @@ export const createGenerationTopicSlice: StateCreator<
231
228
  ),
232
229
 
233
230
  refreshGenerationTopics: async () => {
234
- await mutate([FETCH_GENERATION_TOPICS_KEY, true]);
231
+ await mutate([FETCH_GENERATION_TOPICS_KEY]);
235
232
  },
236
233
 
237
234
  removeGenerationTopic: async (id: string) => {
@@ -0,0 +1,61 @@
1
+ import debug from 'debug';
2
+ import { NextRequest } from 'next/server';
3
+
4
+ const log = debug('lobe-oidc:correctOIDCUrl');
5
+
6
+ /**
7
+ * 修复 OIDC 重定向 URL 在代理环境下的问题
8
+ * @param req - Next.js 请求对象
9
+ * @param url - 要修复的 URL 对象
10
+ * @returns 修复后的 URL 对象
11
+ */
12
+ export const correctOIDCUrl = (req: NextRequest, url: URL): URL => {
13
+ const requestHost = req.headers.get('host');
14
+ const forwardedHost = req.headers.get('x-forwarded-host');
15
+ const forwardedProto =
16
+ req.headers.get('x-forwarded-proto') || req.headers.get('x-forwarded-protocol');
17
+
18
+ log('Input URL: %s', url.toString());
19
+ log(
20
+ 'Request headers - host: %s, x-forwarded-host: %s, x-forwarded-proto: %s',
21
+ requestHost,
22
+ forwardedHost,
23
+ forwardedProto,
24
+ );
25
+
26
+ // 确定实际的主机名和协议,提供后备值
27
+ const actualHost = forwardedHost || requestHost;
28
+ const actualProto = forwardedProto || (url.protocol === 'https:' ? 'https' : 'http');
29
+
30
+ // 如果无法确定有效的主机名,直接返回原URL
31
+ if (!actualHost || actualHost === 'null') {
32
+ log('Warning: Cannot determine valid host, returning original URL');
33
+ return url;
34
+ }
35
+
36
+ // 如果 URL 指向本地地址,或者主机名与实际请求主机不匹配,则修正 URL
37
+ const needsCorrection =
38
+ url.hostname === 'localhost' ||
39
+ url.hostname === '127.0.0.1' ||
40
+ url.hostname === '0.0.0.0' ||
41
+ url.hostname !== actualHost;
42
+
43
+ if (needsCorrection) {
44
+ log('URL needs correction. Original hostname: %s, correcting to: %s', url.hostname, actualHost);
45
+
46
+ try {
47
+ const correctedUrl = new URL(url.toString());
48
+ correctedUrl.protocol = actualProto + ':';
49
+ correctedUrl.host = actualHost;
50
+
51
+ log('Corrected URL: %s', correctedUrl.toString());
52
+ return correctedUrl;
53
+ } catch (error) {
54
+ log('Error creating corrected URL, returning original: %O', error);
55
+ return url;
56
+ }
57
+ }
58
+
59
+ log('URL does not need correction, returning original: %s', url.toString());
60
+ return url;
61
+ };
@@ -1,13 +0,0 @@
1
- import { useGlobalStore } from '@/store/global';
2
- import { systemStatusSelectors } from '@/store/global/selectors';
3
- import { useImageStore } from '@/store/image';
4
- import { useUserStore } from '@/store/user';
5
- import { authSelectors } from '@/store/user/slices/auth/selectors';
6
-
7
- export const useFetchGenerationTopics = () => {
8
- const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
9
- const isLogin = useUserStore(authSelectors.isLogin);
10
- const useFetchGenerationTopics = useImageStore((s) => s.useFetchGenerationTopics);
11
-
12
- useFetchGenerationTopics(isDBInited, isLogin);
13
- };