@lobehub/lobehub 2.0.0-next.239 → 2.0.0-next.240

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.cursor/rules/typescript.mdc +1 -0
  2. package/CHANGELOG.md +33 -0
  3. package/changelog/v1.json +5 -0
  4. package/locales/en-US/plugin.json +9 -0
  5. package/locales/zh-CN/plugin.json +9 -0
  6. package/package.json +1 -1
  7. package/packages/builtin-tool-gtd/src/client/Streaming/CreatePlan/index.tsx +4 -19
  8. package/packages/builtin-tool-notebook/src/client/Inspector/CreateDocument/index.tsx +51 -0
  9. package/packages/builtin-tool-notebook/src/client/Inspector/index.ts +14 -0
  10. package/packages/builtin-tool-notebook/src/client/Placeholder/CreateDocument.tsx +101 -0
  11. package/packages/builtin-tool-notebook/src/client/Placeholder/index.ts +10 -0
  12. package/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx +63 -33
  13. package/packages/builtin-tool-notebook/src/client/Streaming/CreateDocument/index.tsx +75 -0
  14. package/packages/builtin-tool-notebook/src/client/Streaming/index.ts +14 -0
  15. package/packages/builtin-tool-notebook/src/client/components/AnimatedNumber.tsx +57 -0
  16. package/packages/builtin-tool-notebook/src/client/index.ts +12 -0
  17. package/packages/builtin-tool-notebook/src/systemRole.ts +2 -1
  18. package/packages/memory-user-memory/src/extractors/base.ts +8 -13
  19. package/packages/memory-user-memory/src/extractors/context.test.ts +2 -7
  20. package/packages/memory-user-memory/src/extractors/context.ts +7 -2
  21. package/packages/memory-user-memory/src/extractors/experience.test.ts +2 -10
  22. package/packages/memory-user-memory/src/extractors/experience.ts +7 -2
  23. package/packages/memory-user-memory/src/extractors/gatekeeper.test.ts +2 -7
  24. package/packages/memory-user-memory/src/extractors/gatekeeper.ts +3 -2
  25. package/packages/memory-user-memory/src/extractors/identity.test.ts +2 -7
  26. package/packages/memory-user-memory/src/extractors/identity.ts +7 -2
  27. package/packages/memory-user-memory/src/extractors/preference.test.ts +2 -10
  28. package/packages/memory-user-memory/src/extractors/preference.ts +7 -2
  29. package/packages/memory-user-memory/src/prompts/gatekeeper.ts +127 -0
  30. package/packages/memory-user-memory/src/prompts/index.ts +2 -0
  31. package/packages/memory-user-memory/src/prompts/layers/context.ts +155 -0
  32. package/packages/memory-user-memory/src/prompts/layers/experience.ts +162 -0
  33. package/packages/memory-user-memory/src/prompts/layers/identity.ts +219 -0
  34. package/packages/memory-user-memory/src/prompts/layers/index.ts +4 -0
  35. package/packages/memory-user-memory/src/prompts/layers/preference.ts +164 -0
  36. package/packages/memory-user-memory/src/services/extractExecutor.ts +0 -7
  37. package/packages/memory-user-memory/src/types.ts +0 -1
  38. package/src/app/[variants]/(main)/image/features/GenerationFeed/index.tsx +2 -2
  39. package/src/app/[variants]/(main)/image/features/ImageWorkspace/Content.tsx +1 -11
  40. package/src/app/[variants]/(main)/image/features/PromptInput/index.tsx +1 -7
  41. package/src/app/[variants]/(main)/image/index.tsx +2 -5
  42. package/src/components/Loading/BrandTextLoading/index.module.css +0 -1
  43. package/src/components/StreamingMarkdown/index.tsx +88 -0
  44. package/src/features/Conversation/Messages/AssistantGroup/Tool/Render/index.tsx +3 -5
  45. package/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx +14 -0
  46. package/src/features/PluginDevModal/PluginPreview/EmptyState.tsx +1 -1
  47. package/src/locales/default/plugin.ts +9 -0
  48. package/src/server/routers/async/image.ts +1 -1
  49. package/src/server/routers/lambda/image/index.test.ts +491 -0
  50. package/src/server/routers/lambda/{image.ts → image/index.ts} +57 -41
  51. package/src/server/routers/lambda/{__tests__/image.test.ts → image/utils.test.ts} +1 -21
  52. package/src/server/routers/lambda/image/utils.ts +24 -0
  53. package/src/server/services/file/__tests__/index.test.ts +3 -3
  54. package/src/server/services/file/impls/index.ts +4 -4
  55. package/src/server/services/file/impls/s3.test.ts +57 -39
  56. package/src/server/services/file/impls/s3.ts +29 -21
  57. package/src/server/services/file/impls/type.ts +1 -2
  58. package/src/server/services/file/index.ts +5 -3
  59. package/src/tools/inspectors.ts +2 -0
  60. package/src/tools/placeholders.ts +5 -0
  61. package/src/tools/streamings.ts +2 -0
  62. package/packages/memory-user-memory/src/prompts/gatekeeper.md +0 -125
  63. package/packages/memory-user-memory/src/prompts/layers/context.md +0 -153
  64. package/packages/memory-user-memory/src/prompts/layers/experience.md +0 -160
  65. package/packages/memory-user-memory/src/prompts/layers/identity.md +0 -217
  66. package/packages/memory-user-memory/src/prompts/layers/preference.md +0 -162
  67. package/packages/memory-user-memory/src/utils/path.ts +0 -5
  68. package/src/server/services/file/impls/utils.test.ts +0 -154
  69. package/src/server/services/file/impls/utils.ts +0 -17
@@ -0,0 +1,491 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { AsyncTaskStatus, AsyncTaskType } from '@/types/asyncTask';
4
+
5
+ // Use vi.hoisted for variables used in vi.mock factory
6
+ const {
7
+ mockServerDB,
8
+ mockGetKeyFromFullUrl,
9
+ mockGetFullFileUrl,
10
+ mockAsyncTaskModelUpdate,
11
+ mockChargeBeforeGenerate,
12
+ mockCreateAsyncCaller,
13
+ } = vi.hoisted(() => ({
14
+ mockServerDB: {
15
+ transaction: vi.fn(),
16
+ },
17
+ mockGetKeyFromFullUrl: vi.fn(),
18
+ mockGetFullFileUrl: vi.fn(),
19
+ mockAsyncTaskModelUpdate: vi.fn(),
20
+ mockChargeBeforeGenerate: vi.fn(),
21
+ mockCreateAsyncCaller: vi.fn(),
22
+ }));
23
+
24
+ // Mock debug
25
+ vi.mock('debug', () => ({
26
+ default: () => () => {},
27
+ }));
28
+
29
+ // Mock auth related
30
+ vi.mock('@lobechat/utils/server', () => ({
31
+ getXorPayload: vi.fn(() => ({})),
32
+ }));
33
+
34
+ // Mock database adaptor
35
+ vi.mock('@/database/core/db-adaptor', () => ({
36
+ getServerDB: vi.fn(async () => mockServerDB),
37
+ }));
38
+
39
+ // Mock FileService
40
+ vi.mock('@/server/services/file', () => ({
41
+ FileService: vi.fn(() => ({
42
+ getKeyFromFullUrl: mockGetKeyFromFullUrl,
43
+ getFullFileUrl: mockGetFullFileUrl,
44
+ })),
45
+ }));
46
+
47
+ // Mock AsyncTaskModel
48
+ vi.mock('@/database/models/asyncTask', () => ({
49
+ AsyncTaskModel: vi.fn(() => ({
50
+ update: mockAsyncTaskModelUpdate,
51
+ })),
52
+ }));
53
+
54
+ // Mock chargeBeforeGenerate
55
+ vi.mock('@/business/server/image-generation/chargeBeforeGenerate', () => ({
56
+ chargeBeforeGenerate: (params: any) => mockChargeBeforeGenerate(params),
57
+ }));
58
+
59
+ // Mock async caller
60
+ vi.mock('@/server/routers/async/caller', () => ({
61
+ createAsyncCaller: mockCreateAsyncCaller,
62
+ }));
63
+
64
+ // Mock drizzle-orm
65
+ vi.mock('drizzle-orm', () => ({
66
+ and: vi.fn((...args) => args),
67
+ eq: vi.fn((a, b) => ({ a, b })),
68
+ }));
69
+
70
+ // Mock database schemas
71
+ vi.mock('@/database/schemas', () => ({
72
+ asyncTasks: { id: 'asyncTasks.id', userId: 'asyncTasks.userId' },
73
+ generationBatches: { id: 'generationBatches.id' },
74
+ generations: { id: 'generations.id', userId: 'generations.userId' },
75
+ }));
76
+
77
+ // Mock seed generator
78
+ vi.mock('@/utils/number', () => ({
79
+ generateUniqueSeeds: vi.fn((count: number) => Array.from({ length: count }, (_, i) => 1000 + i)),
80
+ }));
81
+
82
+ import { imageRouter } from './index';
83
+
84
+ describe('imageRouter', () => {
85
+ const mockUserId = 'test-user-id';
86
+ const mockAsyncCallerCreateImage = vi.fn();
87
+
88
+ const createMockCtx = (overrides = {}) => ({
89
+ userId: mockUserId,
90
+ authorizationHeader: 'mock-auth-header',
91
+ ...overrides,
92
+ });
93
+
94
+ const createDefaultInput = (overrides = {}) => ({
95
+ generationTopicId: 'topic-1',
96
+ imageNum: 2,
97
+ model: 'stable-diffusion',
98
+ params: {
99
+ prompt: 'a beautiful sunset',
100
+ width: 512,
101
+ height: 512,
102
+ },
103
+ provider: 'test-provider',
104
+ ...overrides,
105
+ });
106
+
107
+ beforeEach(() => {
108
+ vi.clearAllMocks();
109
+
110
+ // Default mock implementations
111
+ mockChargeBeforeGenerate.mockResolvedValue(undefined);
112
+ mockGetKeyFromFullUrl.mockResolvedValue(null);
113
+ mockGetFullFileUrl.mockResolvedValue(null);
114
+
115
+ // Setup default transaction mock
116
+ const mockBatch = {
117
+ id: 'batch-1',
118
+ generationTopicId: 'topic-1',
119
+ model: 'stable-diffusion',
120
+ provider: 'test-provider',
121
+ config: {},
122
+ userId: mockUserId,
123
+ };
124
+
125
+ const mockGenerations = [
126
+ { id: 'gen-1', generationBatchId: 'batch-1', seed: 1000, userId: mockUserId },
127
+ { id: 'gen-2', generationBatchId: 'batch-1', seed: 1001, userId: mockUserId },
128
+ ];
129
+
130
+ const mockAsyncTasks = [
131
+ { id: 'task-1', status: AsyncTaskStatus.Pending, type: AsyncTaskType.ImageGeneration },
132
+ { id: 'task-2', status: AsyncTaskStatus.Pending, type: AsyncTaskType.ImageGeneration },
133
+ ];
134
+
135
+ let insertCallCount = 0;
136
+ mockServerDB.transaction.mockImplementation(async (callback) => {
137
+ insertCallCount = 0;
138
+ const tx = {
139
+ insert: vi.fn().mockReturnValue({
140
+ values: vi.fn().mockReturnValue({
141
+ returning: vi.fn().mockImplementation(() => {
142
+ insertCallCount++;
143
+ if (insertCallCount === 1) return [mockBatch];
144
+ if (insertCallCount === 2) return mockGenerations;
145
+ // For async tasks, return one at a time
146
+ const taskIndex = insertCallCount - 3;
147
+ return [mockAsyncTasks[taskIndex] || mockAsyncTasks[0]];
148
+ }),
149
+ }),
150
+ }),
151
+ update: vi.fn().mockReturnValue({
152
+ set: vi.fn().mockReturnValue({
153
+ where: vi.fn().mockResolvedValue(undefined),
154
+ }),
155
+ }),
156
+ };
157
+
158
+ return callback(tx);
159
+ });
160
+
161
+ mockCreateAsyncCaller.mockResolvedValue({
162
+ image: {
163
+ createImage: mockAsyncCallerCreateImage,
164
+ },
165
+ });
166
+ });
167
+
168
+ describe('createImage', () => {
169
+ it('should create image generation batch and generations successfully', async () => {
170
+ const ctx = createMockCtx();
171
+ const input = createDefaultInput();
172
+
173
+ const caller = imageRouter.createCaller(ctx);
174
+ const result = await caller.createImage(input);
175
+
176
+ expect(result.success).toBe(true);
177
+ expect(result.data.batch).toBeDefined();
178
+ expect(result.data.batch.id).toBe('batch-1');
179
+ expect(result.data.generations).toHaveLength(2);
180
+ expect(mockServerDB.transaction).toHaveBeenCalled();
181
+ });
182
+
183
+ it('should convert imageUrls to S3 keys for database storage', async () => {
184
+ mockGetKeyFromFullUrl
185
+ .mockResolvedValueOnce('files/image1.jpg')
186
+ .mockResolvedValueOnce('files/image2.jpg');
187
+
188
+ const ctx = createMockCtx();
189
+ const input = createDefaultInput({
190
+ params: {
191
+ prompt: 'test prompt',
192
+ imageUrls: [
193
+ 'https://s3.amazonaws.com/bucket/files/image1.jpg',
194
+ 'https://s3.amazonaws.com/bucket/files/image2.jpg',
195
+ ],
196
+ },
197
+ });
198
+
199
+ const caller = imageRouter.createCaller(ctx);
200
+ await caller.createImage(input);
201
+
202
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledTimes(2);
203
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(
204
+ 'https://s3.amazonaws.com/bucket/files/image1.jpg',
205
+ );
206
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(
207
+ 'https://s3.amazonaws.com/bucket/files/image2.jpg',
208
+ );
209
+ });
210
+
211
+ it('should convert single imageUrl to S3 key for database storage', async () => {
212
+ mockGetKeyFromFullUrl.mockResolvedValue('files/single-image.jpg');
213
+
214
+ const ctx = createMockCtx();
215
+ const input = createDefaultInput({
216
+ params: {
217
+ prompt: 'test prompt',
218
+ imageUrl: 'https://s3.amazonaws.com/bucket/files/single-image.jpg',
219
+ },
220
+ });
221
+
222
+ const caller = imageRouter.createCaller(ctx);
223
+ await caller.createImage(input);
224
+
225
+ expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(
226
+ 'https://s3.amazonaws.com/bucket/files/single-image.jpg',
227
+ );
228
+ });
229
+
230
+ it('should handle failed URL to key conversion gracefully for imageUrls', async () => {
231
+ mockGetKeyFromFullUrl.mockResolvedValue(null);
232
+
233
+ const ctx = createMockCtx();
234
+ const input = createDefaultInput({
235
+ params: {
236
+ prompt: 'test prompt',
237
+ imageUrls: ['https://example.com/image.jpg'],
238
+ },
239
+ });
240
+
241
+ const caller = imageRouter.createCaller(ctx);
242
+ const result = await caller.createImage(input);
243
+
244
+ // Should still succeed, just with empty imageUrls in config
245
+ expect(result.success).toBe(true);
246
+ });
247
+
248
+ it('should throw error when imageUrls conversion fails and URLs remain', async () => {
249
+ mockGetKeyFromFullUrl.mockRejectedValue(new Error('Conversion failed'));
250
+
251
+ const ctx = createMockCtx();
252
+ const input = createDefaultInput({
253
+ params: {
254
+ prompt: 'test prompt',
255
+ imageUrls: ['https://example.com/image.jpg'],
256
+ },
257
+ });
258
+
259
+ const caller = imageRouter.createCaller(ctx);
260
+
261
+ // When conversion fails, the original URL is kept but validateNoUrlsInConfig
262
+ // will detect it and throw an error to prevent storing URLs in database
263
+ await expect(caller.createImage(input)).rejects.toThrow(
264
+ 'Invalid configuration: Found full URL instead of key',
265
+ );
266
+ });
267
+
268
+ it('should throw error when single imageUrl conversion fails and URL remains', async () => {
269
+ mockGetKeyFromFullUrl.mockRejectedValue(new Error('Conversion failed'));
270
+
271
+ const ctx = createMockCtx();
272
+ const input = createDefaultInput({
273
+ params: {
274
+ prompt: 'test prompt',
275
+ imageUrl: 'https://example.com/image.jpg',
276
+ },
277
+ });
278
+
279
+ const caller = imageRouter.createCaller(ctx);
280
+
281
+ // When conversion fails, the original URL is kept but validateNoUrlsInConfig
282
+ // will detect it and throw an error to prevent storing URLs in database
283
+ await expect(caller.createImage(input)).rejects.toThrow(
284
+ 'Invalid configuration: Found full URL instead of key',
285
+ );
286
+ });
287
+
288
+ it('should return charge result when chargeBeforeGenerate returns a value', async () => {
289
+ const chargeResult = {
290
+ success: true as const,
291
+ data: {
292
+ batch: { id: 'charged-batch' },
293
+ generations: [{ id: 'charged-gen' }],
294
+ },
295
+ };
296
+ mockChargeBeforeGenerate.mockResolvedValue(chargeResult);
297
+
298
+ const ctx = createMockCtx();
299
+ const input = createDefaultInput();
300
+
301
+ const caller = imageRouter.createCaller(ctx);
302
+ const result = await caller.createImage(input);
303
+
304
+ expect(result).toEqual(chargeResult);
305
+ // Should not proceed with database transaction
306
+ expect(mockServerDB.transaction).not.toHaveBeenCalled();
307
+ });
308
+
309
+ it('should call chargeBeforeGenerate with correct parameters', async () => {
310
+ const ctx = createMockCtx();
311
+ const input = createDefaultInput();
312
+
313
+ const caller = imageRouter.createCaller(ctx);
314
+ await caller.createImage(input);
315
+
316
+ expect(mockChargeBeforeGenerate).toHaveBeenCalledWith(
317
+ expect.objectContaining({
318
+ generationTopicId: 'topic-1',
319
+ imageNum: 2,
320
+ model: 'stable-diffusion',
321
+ provider: 'test-provider',
322
+ userId: mockUserId,
323
+ }),
324
+ );
325
+ });
326
+
327
+ it('should trigger async image generation tasks', async () => {
328
+ const ctx = createMockCtx();
329
+ const input = createDefaultInput();
330
+
331
+ const caller = imageRouter.createCaller(ctx);
332
+ await caller.createImage(input);
333
+
334
+ expect(mockCreateAsyncCaller).toHaveBeenCalledWith({ userId: mockUserId });
335
+ });
336
+
337
+ it('should handle async caller creation failure', async () => {
338
+ mockCreateAsyncCaller.mockRejectedValue(new Error('Caller creation failed'));
339
+
340
+ const ctx = createMockCtx();
341
+ const input = createDefaultInput();
342
+
343
+ const caller = imageRouter.createCaller(ctx);
344
+ const result = await caller.createImage(input);
345
+
346
+ // Should still return success as the database records were created
347
+ expect(result.success).toBe(true);
348
+ // Should update async task status to error
349
+ expect(mockAsyncTaskModelUpdate).toHaveBeenCalled();
350
+ });
351
+
352
+ it('should update all task statuses to error when async processing fails', async () => {
353
+ mockCreateAsyncCaller.mockRejectedValue(new Error('Processing failed'));
354
+
355
+ const ctx = createMockCtx();
356
+ const input = createDefaultInput();
357
+
358
+ const caller = imageRouter.createCaller(ctx);
359
+ await caller.createImage(input);
360
+
361
+ // Should update both tasks to error status
362
+ expect(mockAsyncTaskModelUpdate).toHaveBeenCalledTimes(2);
363
+ expect(mockAsyncTaskModelUpdate).toHaveBeenCalledWith(
364
+ expect.any(String),
365
+ expect.objectContaining({
366
+ status: AsyncTaskStatus.Error,
367
+ }),
368
+ );
369
+ });
370
+
371
+ it('should generate unique seeds when seed param is provided', async () => {
372
+ const ctx = createMockCtx();
373
+ const input = createDefaultInput({
374
+ params: {
375
+ prompt: 'test prompt',
376
+ seed: 42,
377
+ },
378
+ });
379
+
380
+ const caller = imageRouter.createCaller(ctx);
381
+ await caller.createImage(input);
382
+
383
+ expect(mockServerDB.transaction).toHaveBeenCalled();
384
+ });
385
+
386
+ it('should use null seeds when seed param is not provided', async () => {
387
+ const ctx = createMockCtx();
388
+ const input = createDefaultInput({
389
+ params: {
390
+ prompt: 'test prompt',
391
+ // No seed param
392
+ },
393
+ });
394
+
395
+ const caller = imageRouter.createCaller(ctx);
396
+ await caller.createImage(input);
397
+
398
+ expect(mockServerDB.transaction).toHaveBeenCalled();
399
+ });
400
+
401
+ it('should pass with valid key-based imageUrls', async () => {
402
+ mockGetKeyFromFullUrl.mockResolvedValue('files/valid-key.jpg');
403
+
404
+ const ctx = createMockCtx();
405
+ const input = createDefaultInput({
406
+ params: {
407
+ prompt: 'test prompt',
408
+ imageUrls: ['files/valid-key.jpg'],
409
+ },
410
+ });
411
+
412
+ const caller = imageRouter.createCaller(ctx);
413
+ const result = await caller.createImage(input);
414
+
415
+ expect(result.success).toBe(true);
416
+ });
417
+
418
+ describe('development environment URL conversion', () => {
419
+ beforeEach(() => {
420
+ vi.stubEnv('NODE_ENV', 'development');
421
+ });
422
+
423
+ afterEach(() => {
424
+ vi.unstubAllEnvs();
425
+ });
426
+
427
+ it('should convert single imageUrl to S3 URL in development mode', async () => {
428
+ mockGetKeyFromFullUrl.mockResolvedValue('files/image-key.jpg');
429
+ mockGetFullFileUrl.mockResolvedValue('https://s3.amazonaws.com/bucket/files/image-key.jpg');
430
+
431
+ const ctx = createMockCtx();
432
+ const input = createDefaultInput({
433
+ params: {
434
+ prompt: 'test prompt',
435
+ imageUrl: 'http://localhost:3000/f/file-id',
436
+ },
437
+ });
438
+
439
+ const caller = imageRouter.createCaller(ctx);
440
+ const result = await caller.createImage(input);
441
+
442
+ expect(result.success).toBe(true);
443
+ expect(mockGetFullFileUrl).toHaveBeenCalledWith('files/image-key.jpg');
444
+ });
445
+
446
+ it('should convert multiple imageUrls to S3 URLs in development mode', async () => {
447
+ mockGetKeyFromFullUrl
448
+ .mockResolvedValueOnce('files/image1.jpg')
449
+ .mockResolvedValueOnce('files/image2.jpg');
450
+ mockGetFullFileUrl
451
+ .mockResolvedValueOnce('https://s3.amazonaws.com/bucket/files/image1.jpg')
452
+ .mockResolvedValueOnce('https://s3.amazonaws.com/bucket/files/image2.jpg');
453
+
454
+ const ctx = createMockCtx();
455
+ const input = createDefaultInput({
456
+ params: {
457
+ prompt: 'test prompt',
458
+ imageUrls: ['http://localhost:3000/f/id1', 'http://localhost:3000/f/id2'],
459
+ },
460
+ });
461
+
462
+ const caller = imageRouter.createCaller(ctx);
463
+ const result = await caller.createImage(input);
464
+
465
+ expect(result.success).toBe(true);
466
+ expect(mockGetFullFileUrl).toHaveBeenCalledTimes(2);
467
+ expect(mockGetFullFileUrl).toHaveBeenCalledWith('files/image1.jpg');
468
+ expect(mockGetFullFileUrl).toHaveBeenCalledWith('files/image2.jpg');
469
+ });
470
+
471
+ it('should not convert URLs when getFullFileUrl returns null', async () => {
472
+ mockGetKeyFromFullUrl.mockResolvedValue('files/image-key.jpg');
473
+ mockGetFullFileUrl.mockResolvedValue(null);
474
+
475
+ const ctx = createMockCtx();
476
+ const input = createDefaultInput({
477
+ params: {
478
+ prompt: 'test prompt',
479
+ imageUrl: 'http://localhost:3000/f/file-id',
480
+ },
481
+ });
482
+
483
+ const caller = imageRouter.createCaller(ctx);
484
+ const result = await caller.createImage(input);
485
+
486
+ expect(result.success).toBe(true);
487
+ expect(mockGetFullFileUrl).toHaveBeenCalled();
488
+ });
489
+ });
490
+ });
491
+ });
@@ -23,32 +23,9 @@ import {
23
23
  } from '@/types/asyncTask';
24
24
  import { generateUniqueSeeds } from '@/utils/number';
25
25
 
26
- const log = debug('lobe-image:lambda');
26
+ import { validateNoUrlsInConfig } from './utils';
27
27
 
28
- /**
29
- * Recursively validate that no full URLs are present in the config
30
- * This is a defensive check to ensure only keys are stored in database
31
- */
32
- function validateNoUrlsInConfig(obj: any, path: string = ''): void {
33
- if (typeof obj === 'string') {
34
- if (obj.startsWith('http://') || obj.startsWith('https://')) {
35
- throw new Error(
36
- `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
37
- `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
38
- `All URLs must be converted to storage keys before database insertion.`,
39
- );
40
- }
41
- } else if (Array.isArray(obj)) {
42
- obj.forEach((item, index) => {
43
- validateNoUrlsInConfig(item, `${path}[${index}]`);
44
- });
45
- } else if (obj && typeof obj === 'object') {
46
- Object.entries(obj).forEach(([key, value]) => {
47
- const currentPath = path ? `${path}.${key}` : key;
48
- validateNoUrlsInConfig(value, currentPath);
49
- });
50
- }
51
- }
28
+ const log = debug('lobe-image:lambda');
52
29
 
53
30
  const imageProcedure = authedProcedure
54
31
  .use(keyVaults)
@@ -103,11 +80,18 @@ export const imageRouter = router({
103
80
  if (Array.isArray(params.imageUrls) && params.imageUrls.length > 0) {
104
81
  log('Converting imageUrls to S3 keys for database storage: %O', params.imageUrls);
105
82
  try {
106
- const imageKeys = params.imageUrls.map((url) => {
107
- const key = fileService.getKeyFromFullUrl(url);
108
- log('Converted URL %s to key %s', url, key);
109
- return key;
110
- });
83
+ const imageKeysWithNull = await Promise.all(
84
+ params.imageUrls.map(async (url) => {
85
+ const key = await fileService.getKeyFromFullUrl(url);
86
+ if (key) {
87
+ log('Converted URL %s to key %s', url, key);
88
+ } else {
89
+ log('Failed to extract key from URL: %s', url);
90
+ }
91
+ return key;
92
+ }),
93
+ );
94
+ const imageKeys = imageKeysWithNull.filter((key): key is string => key !== null);
111
95
 
112
96
  configForDatabase = {
113
97
  ...configForDatabase,
@@ -115,29 +99,61 @@ export const imageRouter = router({
115
99
  };
116
100
  log('Successfully converted imageUrls to keys for database: %O', imageKeys);
117
101
  } catch (error) {
118
- log('Error converting imageUrls to keys: %O', error);
119
- log('Keeping original imageUrls due to conversion error');
102
+ console.error('Error converting imageUrls to keys: %O', error);
103
+ console.error('Keeping original imageUrls due to conversion error');
120
104
  }
121
105
  }
122
106
  // 2) Process single image in imageUrl
123
107
  if (typeof params.imageUrl === 'string' && params.imageUrl) {
124
108
  try {
125
- const key = fileService.getKeyFromFullUrl(params.imageUrl);
126
- log('Converted single imageUrl to key: %s -> %s', params.imageUrl, key);
127
- configForDatabase = { ...configForDatabase, imageUrl: key };
109
+ const key = await fileService.getKeyFromFullUrl(params.imageUrl);
110
+ if (key) {
111
+ log('Converted single imageUrl to key: %s -> %s', params.imageUrl, key);
112
+ configForDatabase = { ...configForDatabase, imageUrl: key };
113
+ } else {
114
+ log('Failed to extract key from single imageUrl: %s', params.imageUrl);
115
+ }
128
116
  } catch (error) {
129
- log('Error converting imageUrl to key: %O', error);
117
+ console.error('Error converting imageUrl to key: %O', error);
130
118
  // Keep original value if conversion fails
131
119
  }
132
120
  }
133
121
 
122
+ // In development, convert localhost proxy URLs to S3 URLs for async task access
123
+ let generationParams = params;
124
+ if (process.env.NODE_ENV === 'development') {
125
+ const updates: Record<string, unknown> = {};
126
+
127
+ // Handle single imageUrl: localhost/f/{id} -> S3 URL
128
+ if (typeof params.imageUrl === 'string' && params.imageUrl) {
129
+ const s3Url = await fileService.getFullFileUrl(configForDatabase.imageUrl as string);
130
+ if (s3Url) {
131
+ log('Dev: converted proxy URL to S3 URL: %s -> %s', params.imageUrl, s3Url);
132
+ updates.imageUrl = s3Url;
133
+ }
134
+ }
135
+
136
+ // Handle multiple imageUrls
137
+ if (Array.isArray(params.imageUrls) && params.imageUrls.length > 0) {
138
+ const s3Urls = await Promise.all(
139
+ (configForDatabase.imageUrls as string[]).map((key) => fileService.getFullFileUrl(key)),
140
+ );
141
+ log('Dev: converted proxy URLs to S3 URLs: %O', s3Urls);
142
+ updates.imageUrls = s3Urls;
143
+ }
144
+
145
+ if (Object.keys(updates).length > 0) {
146
+ generationParams = { ...params, ...updates };
147
+ }
148
+ }
149
+
134
150
  // Defensive check: ensure no full URLs enter the database
135
151
  validateNoUrlsInConfig(configForDatabase, 'configForDatabase');
136
152
 
137
153
  const chargeResult = await chargeBeforeGenerate({
138
154
  clientIp: ctx.clientIp,
139
155
  configForDatabase,
140
- generationParams: params,
156
+ generationParams,
141
157
  generationTopicId,
142
158
  imageNum,
143
159
  model,
@@ -167,7 +183,7 @@ export const imageRouter = router({
167
183
  const [batch] = await tx.insert(generationBatches).values(newBatch).returning();
168
184
  log('Generation batch created successfully: %s', batch.id);
169
185
 
170
- // 2. Create 4 generations (fixed at 4 images for phase one)
186
+ // 2. Create generations
171
187
  const seeds =
172
188
  'seed' in params
173
189
  ? generateUniqueSeeds(imageNum)
@@ -248,7 +264,7 @@ export const imageRouter = router({
248
264
  generationId: generation.id,
249
265
  generationTopicId,
250
266
  model,
251
- params,
267
+ params: generationParams,
252
268
  provider,
253
269
  taskId: asyncTaskId,
254
270
  });
@@ -256,8 +272,8 @@ export const imageRouter = router({
256
272
 
257
273
  log('All %d background async image generation tasks started', generationsWithTasks.length);
258
274
  } catch (e) {
259
- console.error('[createImage] Failed to process async tasks:', e);
260
- log('Failed to process async tasks: %O', e);
275
+ console.error('Failed to process async tasks:', e);
276
+ console.error('Failed to process async tasks: %O', e);
261
277
 
262
278
  // If overall failure occurs, update all task statuses to failed
263
279
  try {
@@ -1,26 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- // Copy of the validation function from image.ts for testing
4
- function validateNoUrlsInConfig(obj: any, path: string = ''): void {
5
- if (typeof obj === 'string') {
6
- if (obj.startsWith('http://') || obj.startsWith('https://')) {
7
- throw new Error(
8
- `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
9
- `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
10
- `All URLs must be converted to storage keys before database insertion.`,
11
- );
12
- }
13
- } else if (Array.isArray(obj)) {
14
- obj.forEach((item, index) => {
15
- validateNoUrlsInConfig(item, `${path}[${index}]`);
16
- });
17
- } else if (obj && typeof obj === 'object') {
18
- Object.entries(obj).forEach(([key, value]) => {
19
- const currentPath = path ? `${path}.${key}` : key;
20
- validateNoUrlsInConfig(value, currentPath);
21
- });
22
- }
23
- }
3
+ import { validateNoUrlsInConfig } from './utils';
24
4
 
25
5
  describe('imageRouter', () => {
26
6
  describe('validateNoUrlsInConfig utility', () => {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Recursively validate that no full URLs are present in the config
3
+ * This is a defensive check to ensure only keys are stored in database
4
+ */
5
+ export function validateNoUrlsInConfig(obj: any, path: string = ''): void {
6
+ if (typeof obj === 'string') {
7
+ if (obj.startsWith('http://') || obj.startsWith('https://')) {
8
+ throw new Error(
9
+ `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
10
+ `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
11
+ `All URLs must be converted to storage keys before database insertion.`,
12
+ );
13
+ }
14
+ } else if (Array.isArray(obj)) {
15
+ obj.forEach((item, index) => {
16
+ validateNoUrlsInConfig(item, `${path}[${index}]`);
17
+ });
18
+ } else if (obj && typeof obj === 'object') {
19
+ Object.entries(obj).forEach(([key, value]) => {
20
+ const currentPath = path ? `${path}.${key}` : key;
21
+ validateNoUrlsInConfig(value, currentPath);
22
+ });
23
+ }
24
+ }