@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.
- package/.cursor/rules/typescript.mdc +1 -0
- package/CHANGELOG.md +33 -0
- package/changelog/v1.json +5 -0
- package/locales/en-US/plugin.json +9 -0
- package/locales/zh-CN/plugin.json +9 -0
- package/package.json +1 -1
- package/packages/builtin-tool-gtd/src/client/Streaming/CreatePlan/index.tsx +4 -19
- package/packages/builtin-tool-notebook/src/client/Inspector/CreateDocument/index.tsx +51 -0
- package/packages/builtin-tool-notebook/src/client/Inspector/index.ts +14 -0
- package/packages/builtin-tool-notebook/src/client/Placeholder/CreateDocument.tsx +101 -0
- package/packages/builtin-tool-notebook/src/client/Placeholder/index.ts +10 -0
- package/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx +63 -33
- package/packages/builtin-tool-notebook/src/client/Streaming/CreateDocument/index.tsx +75 -0
- package/packages/builtin-tool-notebook/src/client/Streaming/index.ts +14 -0
- package/packages/builtin-tool-notebook/src/client/components/AnimatedNumber.tsx +57 -0
- package/packages/builtin-tool-notebook/src/client/index.ts +12 -0
- package/packages/builtin-tool-notebook/src/systemRole.ts +2 -1
- package/packages/memory-user-memory/src/extractors/base.ts +8 -13
- package/packages/memory-user-memory/src/extractors/context.test.ts +2 -7
- package/packages/memory-user-memory/src/extractors/context.ts +7 -2
- package/packages/memory-user-memory/src/extractors/experience.test.ts +2 -10
- package/packages/memory-user-memory/src/extractors/experience.ts +7 -2
- package/packages/memory-user-memory/src/extractors/gatekeeper.test.ts +2 -7
- package/packages/memory-user-memory/src/extractors/gatekeeper.ts +3 -2
- package/packages/memory-user-memory/src/extractors/identity.test.ts +2 -7
- package/packages/memory-user-memory/src/extractors/identity.ts +7 -2
- package/packages/memory-user-memory/src/extractors/preference.test.ts +2 -10
- package/packages/memory-user-memory/src/extractors/preference.ts +7 -2
- package/packages/memory-user-memory/src/prompts/gatekeeper.ts +127 -0
- package/packages/memory-user-memory/src/prompts/index.ts +2 -0
- package/packages/memory-user-memory/src/prompts/layers/context.ts +155 -0
- package/packages/memory-user-memory/src/prompts/layers/experience.ts +162 -0
- package/packages/memory-user-memory/src/prompts/layers/identity.ts +219 -0
- package/packages/memory-user-memory/src/prompts/layers/index.ts +4 -0
- package/packages/memory-user-memory/src/prompts/layers/preference.ts +164 -0
- package/packages/memory-user-memory/src/services/extractExecutor.ts +0 -7
- package/packages/memory-user-memory/src/types.ts +0 -1
- package/src/app/[variants]/(main)/image/features/GenerationFeed/index.tsx +2 -2
- package/src/app/[variants]/(main)/image/features/ImageWorkspace/Content.tsx +1 -11
- package/src/app/[variants]/(main)/image/features/PromptInput/index.tsx +1 -7
- package/src/app/[variants]/(main)/image/index.tsx +2 -5
- package/src/components/Loading/BrandTextLoading/index.module.css +0 -1
- package/src/components/StreamingMarkdown/index.tsx +88 -0
- package/src/features/Conversation/Messages/AssistantGroup/Tool/Render/index.tsx +3 -5
- package/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx +14 -0
- package/src/features/PluginDevModal/PluginPreview/EmptyState.tsx +1 -1
- package/src/locales/default/plugin.ts +9 -0
- package/src/server/routers/async/image.ts +1 -1
- package/src/server/routers/lambda/image/index.test.ts +491 -0
- package/src/server/routers/lambda/{image.ts → image/index.ts} +57 -41
- package/src/server/routers/lambda/{__tests__/image.test.ts → image/utils.test.ts} +1 -21
- package/src/server/routers/lambda/image/utils.ts +24 -0
- package/src/server/services/file/__tests__/index.test.ts +3 -3
- package/src/server/services/file/impls/index.ts +4 -4
- package/src/server/services/file/impls/s3.test.ts +57 -39
- package/src/server/services/file/impls/s3.ts +29 -21
- package/src/server/services/file/impls/type.ts +1 -2
- package/src/server/services/file/index.ts +5 -3
- package/src/tools/inspectors.ts +2 -0
- package/src/tools/placeholders.ts +5 -0
- package/src/tools/streamings.ts +2 -0
- package/packages/memory-user-memory/src/prompts/gatekeeper.md +0 -125
- package/packages/memory-user-memory/src/prompts/layers/context.md +0 -153
- package/packages/memory-user-memory/src/prompts/layers/experience.md +0 -160
- package/packages/memory-user-memory/src/prompts/layers/identity.md +0 -217
- package/packages/memory-user-memory/src/prompts/layers/preference.md +0 -162
- package/packages/memory-user-memory/src/utils/path.ts +0 -5
- package/src/server/services/file/impls/utils.test.ts +0 -154
- 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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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('
|
|
260
|
-
|
|
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
|
-
|
|
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
|
+
}
|