@lobehub/lobehub 2.0.0-next.137 → 2.0.0-next.139

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 (34) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/CLAUDE.md +38 -0
  3. package/changelog/v1.json +14 -0
  4. package/locales/ar/modelProvider.json +2 -0
  5. package/locales/bg-BG/modelProvider.json +2 -0
  6. package/locales/de-DE/modelProvider.json +2 -0
  7. package/locales/en-US/modelProvider.json +2 -0
  8. package/locales/es-ES/modelProvider.json +2 -0
  9. package/locales/fa-IR/modelProvider.json +2 -0
  10. package/locales/fr-FR/modelProvider.json +2 -0
  11. package/locales/it-IT/modelProvider.json +2 -0
  12. package/locales/ja-JP/modelProvider.json +2 -0
  13. package/locales/ko-KR/modelProvider.json +2 -0
  14. package/locales/nl-NL/modelProvider.json +2 -0
  15. package/locales/pl-PL/modelProvider.json +2 -0
  16. package/locales/pt-BR/modelProvider.json +2 -0
  17. package/locales/ru-RU/modelProvider.json +2 -0
  18. package/locales/tr-TR/modelProvider.json +2 -0
  19. package/locales/vi-VN/modelProvider.json +2 -0
  20. package/locales/zh-CN/modelProvider.json +2 -0
  21. package/locales/zh-TW/modelProvider.json +2 -0
  22. package/package.json +1 -1
  23. package/packages/conversation-flow/src/transformation/BranchResolver.ts +24 -14
  24. package/packages/conversation-flow/src/transformation/ContextTreeBuilder.ts +6 -1
  25. package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +15 -0
  26. package/packages/conversation-flow/src/transformation/__tests__/BranchResolver.test.ts +66 -3
  27. package/packages/conversation-flow/src/transformation/__tests__/ContextTreeBuilder.test.ts +64 -0
  28. package/packages/conversation-flow/src/transformation/__tests__/FlatListBuilder.test.ts +47 -0
  29. package/packages/database/src/models/__tests__/agent.test.ts +102 -3
  30. package/packages/database/src/models/__tests__/document.test.ts +163 -0
  31. package/packages/database/src/models/__tests__/embedding.test.ts +294 -0
  32. package/packages/database/src/models/__tests__/oauthHandoff.test.ts +261 -0
  33. package/packages/database/src/models/__tests__/thread.test.ts +327 -0
  34. package/packages/database/src/models/__tests__/user.test.ts +372 -0
@@ -0,0 +1,294 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+
4
+ import { chunks, embeddings, users } from '../../schemas';
5
+ import { LobeChatDatabase } from '../../type';
6
+ import { EmbeddingModel } from '../embedding';
7
+ import { getTestDB } from './_util';
8
+ import { designThinkingQuery } from './fixtures/embedding';
9
+
10
+ const userId = 'embedding-user-test';
11
+ const otherUserId = 'other-user-test';
12
+
13
+ const serverDB: LobeChatDatabase = await getTestDB();
14
+ const embeddingModel = new EmbeddingModel(serverDB, userId);
15
+
16
+ describe('EmbeddingModel', () => {
17
+ beforeEach(async () => {
18
+ await serverDB.delete(users);
19
+ await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await serverDB.delete(users);
24
+ });
25
+
26
+ describe('create', () => {
27
+ it('should create a new embedding', async () => {
28
+ // Create a chunk first
29
+ const [chunk] = await serverDB
30
+ .insert(chunks)
31
+ .values({ text: 'Test chunk', userId })
32
+ .returning();
33
+
34
+ const id = await embeddingModel.create({
35
+ chunkId: chunk.id,
36
+ embeddings: designThinkingQuery,
37
+ model: 'text-embedding-ada-002',
38
+ });
39
+
40
+ expect(id).toBeDefined();
41
+
42
+ const created = await serverDB.query.embeddings.findFirst({
43
+ where: eq(embeddings.id, id),
44
+ });
45
+
46
+ expect(created).toBeDefined();
47
+ expect(created?.chunkId).toBe(chunk.id);
48
+ expect(created?.model).toBe('text-embedding-ada-002');
49
+ expect(created?.userId).toBe(userId);
50
+ });
51
+ });
52
+
53
+ describe('bulkCreate', () => {
54
+ it('should create multiple embeddings', async () => {
55
+ // Create chunks first
56
+ const [chunk1, chunk2] = await serverDB
57
+ .insert(chunks)
58
+ .values([
59
+ { text: 'Test chunk 1', userId },
60
+ { text: 'Test chunk 2', userId },
61
+ ])
62
+ .returning();
63
+
64
+ await embeddingModel.bulkCreate([
65
+ { chunkId: chunk1.id, embeddings: designThinkingQuery, model: 'text-embedding-ada-002' },
66
+ { chunkId: chunk2.id, embeddings: designThinkingQuery, model: 'text-embedding-ada-002' },
67
+ ]);
68
+
69
+ const created = await serverDB.query.embeddings.findMany({
70
+ where: eq(embeddings.userId, userId),
71
+ });
72
+
73
+ expect(created).toHaveLength(2);
74
+ });
75
+
76
+ it('should handle duplicate chunkId with onConflictDoNothing', async () => {
77
+ // Create a chunk
78
+ const [chunk] = await serverDB
79
+ .insert(chunks)
80
+ .values({ text: 'Test chunk', userId })
81
+ .returning();
82
+
83
+ // Create first embedding
84
+ await embeddingModel.create({
85
+ chunkId: chunk.id,
86
+ embeddings: designThinkingQuery,
87
+ model: 'text-embedding-ada-002',
88
+ });
89
+
90
+ // Try to create duplicate
91
+ await embeddingModel.bulkCreate([
92
+ { chunkId: chunk.id, embeddings: designThinkingQuery, model: 'text-embedding-3-small' },
93
+ ]);
94
+
95
+ const created = await serverDB.query.embeddings.findMany({
96
+ where: eq(embeddings.chunkId, chunk.id),
97
+ });
98
+
99
+ // Should still have only 1 embedding due to unique constraint
100
+ expect(created).toHaveLength(1);
101
+ expect(created[0].model).toBe('text-embedding-ada-002');
102
+ });
103
+ });
104
+
105
+ describe('delete', () => {
106
+ it('should delete an embedding', async () => {
107
+ const [chunk] = await serverDB
108
+ .insert(chunks)
109
+ .values({ text: 'Test chunk', userId })
110
+ .returning();
111
+
112
+ const id = await embeddingModel.create({
113
+ chunkId: chunk.id,
114
+ embeddings: designThinkingQuery,
115
+ model: 'text-embedding-ada-002',
116
+ });
117
+
118
+ await embeddingModel.delete(id);
119
+
120
+ const deleted = await serverDB.query.embeddings.findFirst({
121
+ where: eq(embeddings.id, id),
122
+ });
123
+
124
+ expect(deleted).toBeUndefined();
125
+ });
126
+
127
+ it('should not delete embedding belonging to another user', async () => {
128
+ // Create chunk and embedding for other user
129
+ const [chunk] = await serverDB
130
+ .insert(chunks)
131
+ .values({ text: 'Other user chunk', userId: otherUserId })
132
+ .returning();
133
+
134
+ const [otherEmbedding] = await serverDB
135
+ .insert(embeddings)
136
+ .values({
137
+ chunkId: chunk.id,
138
+ embeddings: designThinkingQuery,
139
+ model: 'text-embedding-ada-002',
140
+ userId: otherUserId,
141
+ })
142
+ .returning();
143
+
144
+ await embeddingModel.delete(otherEmbedding.id);
145
+
146
+ const stillExists = await serverDB.query.embeddings.findFirst({
147
+ where: eq(embeddings.id, otherEmbedding.id),
148
+ });
149
+
150
+ expect(stillExists).toBeDefined();
151
+ });
152
+ });
153
+
154
+ describe('query', () => {
155
+ it('should return all embeddings for the user', async () => {
156
+ const [chunk1, chunk2] = await serverDB
157
+ .insert(chunks)
158
+ .values([
159
+ { text: 'Test chunk 1', userId },
160
+ { text: 'Test chunk 2', userId },
161
+ ])
162
+ .returning();
163
+
164
+ await serverDB.insert(embeddings).values([
165
+ { chunkId: chunk1.id, embeddings: designThinkingQuery, userId },
166
+ { chunkId: chunk2.id, embeddings: designThinkingQuery, userId },
167
+ ]);
168
+
169
+ const result = await embeddingModel.query();
170
+
171
+ expect(result).toHaveLength(2);
172
+ });
173
+
174
+ it('should only return embeddings for the current user', async () => {
175
+ const [chunk1] = await serverDB
176
+ .insert(chunks)
177
+ .values([{ text: 'Test chunk 1', userId }])
178
+ .returning();
179
+
180
+ const [chunk2] = await serverDB
181
+ .insert(chunks)
182
+ .values([{ text: 'Other user chunk', userId: otherUserId }])
183
+ .returning();
184
+
185
+ await serverDB.insert(embeddings).values([
186
+ { chunkId: chunk1.id, embeddings: designThinkingQuery, userId },
187
+ { chunkId: chunk2.id, embeddings: designThinkingQuery, userId: otherUserId },
188
+ ]);
189
+
190
+ const result = await embeddingModel.query();
191
+
192
+ expect(result).toHaveLength(1);
193
+ expect(result[0].userId).toBe(userId);
194
+ });
195
+ });
196
+
197
+ describe('findById', () => {
198
+ it('should return an embedding by id', async () => {
199
+ const [chunk] = await serverDB
200
+ .insert(chunks)
201
+ .values({ text: 'Test chunk', userId })
202
+ .returning();
203
+
204
+ const id = await embeddingModel.create({
205
+ chunkId: chunk.id,
206
+ embeddings: designThinkingQuery,
207
+ model: 'text-embedding-ada-002',
208
+ });
209
+
210
+ const result = await embeddingModel.findById(id);
211
+
212
+ expect(result).toBeDefined();
213
+ expect(result?.id).toBe(id);
214
+ expect(result?.chunkId).toBe(chunk.id);
215
+ });
216
+
217
+ it('should return undefined for non-existent embedding', async () => {
218
+ // Use a valid UUID format that doesn't exist
219
+ const result = await embeddingModel.findById('00000000-0000-0000-0000-000000000000');
220
+
221
+ expect(result).toBeUndefined();
222
+ });
223
+
224
+ it('should not return embedding belonging to another user', async () => {
225
+ const [chunk] = await serverDB
226
+ .insert(chunks)
227
+ .values({ text: 'Other user chunk', userId: otherUserId })
228
+ .returning();
229
+
230
+ const [otherEmbedding] = await serverDB
231
+ .insert(embeddings)
232
+ .values({
233
+ chunkId: chunk.id,
234
+ embeddings: designThinkingQuery,
235
+ userId: otherUserId,
236
+ })
237
+ .returning();
238
+
239
+ const result = await embeddingModel.findById(otherEmbedding.id);
240
+
241
+ expect(result).toBeUndefined();
242
+ });
243
+ });
244
+
245
+ describe('countUsage', () => {
246
+ it('should return the count of embeddings for the user', async () => {
247
+ const [chunk1, chunk2, chunk3] = await serverDB
248
+ .insert(chunks)
249
+ .values([
250
+ { text: 'Test chunk 1', userId },
251
+ { text: 'Test chunk 2', userId },
252
+ { text: 'Test chunk 3', userId },
253
+ ])
254
+ .returning();
255
+
256
+ await serverDB.insert(embeddings).values([
257
+ { chunkId: chunk1.id, embeddings: designThinkingQuery, userId },
258
+ { chunkId: chunk2.id, embeddings: designThinkingQuery, userId },
259
+ { chunkId: chunk3.id, embeddings: designThinkingQuery, userId },
260
+ ]);
261
+
262
+ const count = await embeddingModel.countUsage();
263
+
264
+ expect(count).toBe(3);
265
+ });
266
+
267
+ it('should return 0 when user has no embeddings', async () => {
268
+ const count = await embeddingModel.countUsage();
269
+
270
+ expect(count).toBe(0);
271
+ });
272
+
273
+ it('should only count embeddings for the current user', async () => {
274
+ const [chunk1] = await serverDB
275
+ .insert(chunks)
276
+ .values([{ text: 'Test chunk 1', userId }])
277
+ .returning();
278
+
279
+ const [chunk2] = await serverDB
280
+ .insert(chunks)
281
+ .values([{ text: 'Other user chunk', userId: otherUserId }])
282
+ .returning();
283
+
284
+ await serverDB.insert(embeddings).values([
285
+ { chunkId: chunk1.id, embeddings: designThinkingQuery, userId },
286
+ { chunkId: chunk2.id, embeddings: designThinkingQuery, userId: otherUserId },
287
+ ]);
288
+
289
+ const count = await embeddingModel.countUsage();
290
+
291
+ expect(count).toBe(1);
292
+ });
293
+ });
294
+ });
@@ -0,0 +1,261 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { oauthHandoffs } from '../../schemas';
5
+ import { LobeChatDatabase } from '../../type';
6
+ import { OAuthHandoffModel } from '../oauthHandoff';
7
+ import { getTestDB } from './_util';
8
+
9
+ const serverDB: LobeChatDatabase = await getTestDB();
10
+ const oauthHandoffModel = new OAuthHandoffModel(serverDB);
11
+
12
+ describe('OAuthHandoffModel', () => {
13
+ beforeEach(async () => {
14
+ await serverDB.delete(oauthHandoffs);
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await serverDB.delete(oauthHandoffs);
19
+ vi.useRealTimers();
20
+ });
21
+
22
+ describe('create', () => {
23
+ it('should create a new OAuth handoff record', async () => {
24
+ const result = await oauthHandoffModel.create({
25
+ id: 'handoff-1',
26
+ client: 'desktop',
27
+ payload: { code: 'auth-code', state: 'state-value' },
28
+ });
29
+
30
+ expect(result).toBeDefined();
31
+ expect(result.id).toBe('handoff-1');
32
+ expect(result.client).toBe('desktop');
33
+ expect(result.payload).toEqual({ code: 'auth-code', state: 'state-value' });
34
+ });
35
+
36
+ it('should handle conflict by doing nothing', async () => {
37
+ // Create first record
38
+ await oauthHandoffModel.create({
39
+ id: 'handoff-1',
40
+ client: 'desktop',
41
+ payload: { code: 'original-code' },
42
+ });
43
+
44
+ // Try to create duplicate - should not throw
45
+ const result = await oauthHandoffModel.create({
46
+ id: 'handoff-1',
47
+ client: 'desktop',
48
+ payload: { code: 'new-code' },
49
+ });
50
+
51
+ // Result is undefined when conflict occurs with onConflictDoNothing
52
+ expect(result).toBeUndefined();
53
+
54
+ // Original should remain unchanged
55
+ const record = await serverDB.query.oauthHandoffs.findFirst({
56
+ where: eq(oauthHandoffs.id, 'handoff-1'),
57
+ });
58
+
59
+ expect(record?.payload).toEqual({ code: 'original-code' });
60
+ });
61
+ });
62
+
63
+ describe('fetchAndConsume', () => {
64
+ it('should fetch and delete valid credentials', async () => {
65
+ await oauthHandoffModel.create({
66
+ id: 'handoff-1',
67
+ client: 'desktop',
68
+ payload: { code: 'auth-code', state: 'state-value' },
69
+ });
70
+
71
+ const result = await oauthHandoffModel.fetchAndConsume('handoff-1', 'desktop');
72
+
73
+ expect(result).toBeDefined();
74
+ expect(result?.id).toBe('handoff-1');
75
+ expect(result?.payload).toEqual({ code: 'auth-code', state: 'state-value' });
76
+
77
+ // Verify it was deleted
78
+ const deleted = await serverDB.query.oauthHandoffs.findFirst({
79
+ where: eq(oauthHandoffs.id, 'handoff-1'),
80
+ });
81
+ expect(deleted).toBeUndefined();
82
+ });
83
+
84
+ it('should return null for non-existent credentials', async () => {
85
+ const result = await oauthHandoffModel.fetchAndConsume('non-existent', 'desktop');
86
+
87
+ expect(result).toBeNull();
88
+ });
89
+
90
+ it('should return null when client type does not match', async () => {
91
+ await oauthHandoffModel.create({
92
+ id: 'handoff-1',
93
+ client: 'desktop',
94
+ payload: { code: 'auth-code' },
95
+ });
96
+
97
+ const result = await oauthHandoffModel.fetchAndConsume('handoff-1', 'browser-extension');
98
+
99
+ expect(result).toBeNull();
100
+
101
+ // Record should still exist
102
+ const record = await serverDB.query.oauthHandoffs.findFirst({
103
+ where: eq(oauthHandoffs.id, 'handoff-1'),
104
+ });
105
+ expect(record).toBeDefined();
106
+ });
107
+
108
+ it('should return null for expired credentials (older than 5 minutes)', async () => {
109
+ // Create a record with old timestamp
110
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
111
+
112
+ await serverDB.insert(oauthHandoffs).values({
113
+ id: 'handoff-expired',
114
+ client: 'desktop',
115
+ payload: { code: 'auth-code' },
116
+ createdAt: sixMinutesAgo,
117
+ updatedAt: sixMinutesAgo,
118
+ });
119
+
120
+ const result = await oauthHandoffModel.fetchAndConsume('handoff-expired', 'desktop');
121
+
122
+ expect(result).toBeNull();
123
+ });
124
+
125
+ it('should return valid credentials within 5 minutes', async () => {
126
+ // Create a record with timestamp 4 minutes ago
127
+ const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000);
128
+
129
+ await serverDB.insert(oauthHandoffs).values({
130
+ id: 'handoff-valid',
131
+ client: 'desktop',
132
+ payload: { code: 'auth-code' },
133
+ createdAt: fourMinutesAgo,
134
+ updatedAt: fourMinutesAgo,
135
+ });
136
+
137
+ const result = await oauthHandoffModel.fetchAndConsume('handoff-valid', 'desktop');
138
+
139
+ expect(result).toBeDefined();
140
+ expect(result?.id).toBe('handoff-valid');
141
+ });
142
+ });
143
+
144
+ describe('cleanupExpired', () => {
145
+ it('should delete expired records', async () => {
146
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
147
+ const now = new Date();
148
+
149
+ // Create expired record
150
+ await serverDB.insert(oauthHandoffs).values({
151
+ id: 'handoff-expired',
152
+ client: 'desktop',
153
+ payload: { code: 'expired-code' },
154
+ createdAt: sixMinutesAgo,
155
+ updatedAt: sixMinutesAgo,
156
+ });
157
+
158
+ // Create valid record
159
+ await serverDB.insert(oauthHandoffs).values({
160
+ id: 'handoff-valid',
161
+ client: 'desktop',
162
+ payload: { code: 'valid-code' },
163
+ createdAt: now,
164
+ updatedAt: now,
165
+ });
166
+
167
+ await oauthHandoffModel.cleanupExpired();
168
+
169
+ // Verify expired record is deleted
170
+ const expiredRecord = await serverDB.query.oauthHandoffs.findFirst({
171
+ where: eq(oauthHandoffs.id, 'handoff-expired'),
172
+ });
173
+ expect(expiredRecord).toBeUndefined();
174
+
175
+ // Verify valid record still exists
176
+ const validRecord = await serverDB.query.oauthHandoffs.findFirst({
177
+ where: eq(oauthHandoffs.id, 'handoff-valid'),
178
+ });
179
+ expect(validRecord).toBeDefined();
180
+ });
181
+
182
+ it('should not throw when no expired records exist', async () => {
183
+ await expect(oauthHandoffModel.cleanupExpired()).resolves.not.toThrow();
184
+ });
185
+
186
+ it('should delete multiple expired records', async () => {
187
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
188
+
189
+ await serverDB.insert(oauthHandoffs).values([
190
+ {
191
+ id: 'handoff-expired-1',
192
+ client: 'desktop',
193
+ payload: { code: 'code-1' },
194
+ createdAt: sixMinutesAgo,
195
+ updatedAt: sixMinutesAgo,
196
+ },
197
+ {
198
+ id: 'handoff-expired-2',
199
+ client: 'browser',
200
+ payload: { code: 'code-2' },
201
+ createdAt: sixMinutesAgo,
202
+ updatedAt: sixMinutesAgo,
203
+ },
204
+ ]);
205
+
206
+ await oauthHandoffModel.cleanupExpired();
207
+
208
+ // Verify all expired records are deleted
209
+ const remainingRecords = await serverDB.query.oauthHandoffs.findMany();
210
+ expect(remainingRecords).toHaveLength(0);
211
+ });
212
+ });
213
+
214
+ describe('exists', () => {
215
+ it('should return true for existing valid credentials', async () => {
216
+ await oauthHandoffModel.create({
217
+ id: 'handoff-1',
218
+ client: 'desktop',
219
+ payload: { code: 'auth-code' },
220
+ });
221
+
222
+ const result = await oauthHandoffModel.exists('handoff-1', 'desktop');
223
+
224
+ expect(result).toBe(true);
225
+ });
226
+
227
+ it('should return false for non-existent credentials', async () => {
228
+ const result = await oauthHandoffModel.exists('non-existent', 'desktop');
229
+
230
+ expect(result).toBe(false);
231
+ });
232
+
233
+ it('should return false when client type does not match', async () => {
234
+ await oauthHandoffModel.create({
235
+ id: 'handoff-1',
236
+ client: 'desktop',
237
+ payload: { code: 'auth-code' },
238
+ });
239
+
240
+ const result = await oauthHandoffModel.exists('handoff-1', 'browser-extension');
241
+
242
+ expect(result).toBe(false);
243
+ });
244
+
245
+ it('should return false for expired credentials', async () => {
246
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
247
+
248
+ await serverDB.insert(oauthHandoffs).values({
249
+ id: 'handoff-expired',
250
+ client: 'desktop',
251
+ payload: { code: 'auth-code' },
252
+ createdAt: sixMinutesAgo,
253
+ updatedAt: sixMinutesAgo,
254
+ });
255
+
256
+ const result = await oauthHandoffModel.exists('handoff-expired', 'desktop');
257
+
258
+ expect(result).toBe(false);
259
+ });
260
+ });
261
+ });