@lobehub/chat 1.134.4 → 1.134.5

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.
@@ -252,6 +252,13 @@
252
252
  "when": 1759116400580,
253
253
  "tag": "0035_add_virtual",
254
254
  "breakpoints": true
255
+ },
256
+ {
257
+ "idx": 36,
258
+ "version": "7",
259
+ "when": 1759666151079,
260
+ "tag": "0036_add_group_messages",
261
+ "breakpoints": true
255
262
  }
256
263
  ],
257
264
  "version": "6"
@@ -18,7 +18,7 @@
18
18
  "@lobechat/types": "workspace:*",
19
19
  "@lobechat/utils": "workspace:*",
20
20
  "dayjs": "^1.11.18",
21
- "drizzle-orm": "^0.44.4",
21
+ "drizzle-orm": "^0.44.5",
22
22
  "nanoid": "^5.1.5",
23
23
  "pg": "^8.16.3",
24
24
  "random-words": "^2.0.1",
@@ -632,5 +632,20 @@
632
632
  "bps": true,
633
633
  "folderMillis": 1759116400580,
634
634
  "hash": "433ddae88e785f2db734e49a4c115eee93e60afe389f7919d66e5ba9aa159a37"
635
+ },
636
+ {
637
+ "sql": [
638
+ "CREATE TABLE IF NOT EXISTS \"message_groups\" (\n\t\"id\" varchar(255) PRIMARY KEY NOT NULL,\n\t\"topic_id\" text,\n\t\"user_id\" text NOT NULL,\n\t\"parent_group_id\" varchar(255),\n\t\"parent_message_id\" text,\n\t\"title\" varchar(255),\n\t\"description\" text,\n\t\"client_id\" varchar(255),\n\t\"accessed_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"created_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updated_at\" timestamp with time zone DEFAULT now() NOT NULL\n);\n",
639
+ "\nALTER TABLE \"messages\" ADD COLUMN IF NOT EXISTS \"message_group_id\" varchar(255);",
640
+ "\nALTER TABLE \"message_groups\" ADD CONSTRAINT \"message_groups_topic_id_topics_id_fk\" FOREIGN KEY (\"topic_id\") REFERENCES \"public\".\"topics\"(\"id\") ON DELETE cascade ON UPDATE no action;",
641
+ "\nALTER TABLE \"message_groups\" ADD CONSTRAINT \"message_groups_user_id_users_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;",
642
+ "\nALTER TABLE \"message_groups\" ADD CONSTRAINT \"message_groups_parent_group_id_message_groups_id_fk\" FOREIGN KEY (\"parent_group_id\") REFERENCES \"public\".\"message_groups\"(\"id\") ON DELETE cascade ON UPDATE no action;",
643
+ "\nALTER TABLE \"message_groups\" ADD CONSTRAINT \"message_groups_parent_message_id_messages_id_fk\" FOREIGN KEY (\"parent_message_id\") REFERENCES \"public\".\"messages\"(\"id\") ON DELETE cascade ON UPDATE no action;",
644
+ "\nCREATE UNIQUE INDEX IF NOT EXISTS \"message_groups_client_id_user_id_unique\" ON \"message_groups\" USING btree (\"client_id\",\"user_id\");",
645
+ "\nALTER TABLE \"messages\" ADD CONSTRAINT \"messages_message_group_id_message_groups_id_fk\" FOREIGN KEY (\"message_group_id\") REFERENCES \"public\".\"message_groups\"(\"id\") ON DELETE cascade ON UPDATE no action;\n"
646
+ ],
647
+ "bps": true,
648
+ "folderMillis": 1759666151079,
649
+ "hash": "3a787841eede425d972f2a00cf2f7f8d0b27b391c46ed525c6dceaa79d58d887"
635
650
  }
636
651
  ]
@@ -254,6 +254,15 @@ describe('AiModelModel', () => {
254
254
  expect(allModels.find((m) => m.id === 'existing-model')?.displayName).toBe('Old Name');
255
255
  expect(allModels.find((m) => m.id === 'new-model')?.displayName).toBe('New Model');
256
256
  });
257
+
258
+ it('should return empty array when models array is empty', async () => {
259
+ const result = await aiProviderModel.batchUpdateAiModels('openai', []);
260
+ expect(result).toEqual([]);
261
+
262
+ // Verify no models were created
263
+ const allModels = await aiProviderModel.query();
264
+ expect(allModels).toHaveLength(0);
265
+ });
257
266
  });
258
267
 
259
268
  describe('batchToggleAiModels', () => {
@@ -274,6 +283,23 @@ describe('AiModelModel', () => {
274
283
  const models = await aiProviderModel.query();
275
284
  expect(models.every((m) => m.enabled)).toBe(true);
276
285
  });
286
+
287
+ it('should return early when models array is empty', async () => {
288
+ // Create an initial model to verify it's not affected
289
+ await aiProviderModel.create({
290
+ id: 'model1',
291
+ providerId: 'openai',
292
+ enabled: false,
293
+ });
294
+
295
+ const result = await aiProviderModel.batchToggleAiModels('openai', [], true);
296
+ expect(result).toBeUndefined();
297
+
298
+ // Verify existing models were not affected
299
+ const models = await aiProviderModel.query();
300
+ expect(models).toHaveLength(1);
301
+ expect(models[0].enabled).toBe(false);
302
+ });
277
303
  });
278
304
 
279
305
  describe('clearRemoteModels', () => {
@@ -324,5 +350,22 @@ describe('AiModelModel', () => {
324
350
  const model = await aiProviderModel.findById('image-model');
325
351
  expect(model?.type).toBe('image');
326
352
  });
353
+
354
+ it('should return early when sortMap array is empty', async () => {
355
+ // Create an initial model to verify it's not affected
356
+ await aiProviderModel.create({
357
+ id: 'model1',
358
+ providerId: 'openai',
359
+ sort: 1,
360
+ });
361
+
362
+ const result = await aiProviderModel.updateModelsOrder('openai', []);
363
+ expect(result).toBeUndefined();
364
+
365
+ // Verify existing models were not affected (check by querying the created model directly)
366
+ const models = await aiProviderModel.getModelListByProviderId('openai');
367
+ expect(models).toHaveLength(1);
368
+ expect(models[0].id).toBe('model1');
369
+ });
327
370
  });
328
371
  });
@@ -19,12 +19,21 @@ export class AiModelModel {
19
19
  this.db = db;
20
20
  }
21
21
 
22
+ /**
23
+ * Helper method to validate if array is empty and return early if needed
24
+ * @param array - Array to validate
25
+ * @returns true if array is empty, false otherwise
26
+ */
27
+ private isEmptyArray(array: unknown[]): boolean {
28
+ return array.length === 0;
29
+ }
30
+
22
31
  create = async (params: NewAiModelItem) => {
23
32
  const [result] = await this.db
24
33
  .insert(aiModels)
25
34
  .values({
26
35
  ...params,
27
- enabled: true, // enabled by default
36
+ enabled: params.enabled ?? true, // enabled by default, but respect explicit value
28
37
  source: AiModelSourceEnum.Custom,
29
38
  userId: this.userId,
30
39
  })
@@ -148,6 +157,11 @@ export class AiModelModel {
148
157
  };
149
158
 
150
159
  batchUpdateAiModels = async (providerId: string, models: AiProviderModelListItem[]) => {
160
+ // Early return if models array is empty to prevent database insertion error
161
+ if (this.isEmptyArray(models)) {
162
+ return [];
163
+ }
164
+
151
165
  const records = models.map(({ id, ...model }) => ({
152
166
  ...model,
153
167
  id,
@@ -166,6 +180,11 @@ export class AiModelModel {
166
180
  };
167
181
 
168
182
  batchToggleAiModels = async (providerId: string, models: string[], enabled: boolean) => {
183
+ // Early return if models array is empty to prevent database insertion error
184
+ if (this.isEmptyArray(models)) {
185
+ return;
186
+ }
187
+
169
188
  return this.db.transaction(async (trx) => {
170
189
  // 1. insert models that are not in the db
171
190
  const insertedRecords = await trx
@@ -222,6 +241,11 @@ export class AiModelModel {
222
241
  }
223
242
 
224
243
  updateModelsOrder = async (providerId: string, sortMap: AiModelSortMap[]) => {
244
+ // Early return if sortMap array is empty
245
+ if (this.isEmptyArray(sortMap)) {
246
+ return;
247
+ }
248
+
225
249
  await this.db.transaction(async (tx) => {
226
250
  const updates = sortMap.map(({ id, sort, type }) => {
227
251
  const now = new Date();
@@ -23,7 +23,7 @@ describe('TableViewerRepo', () => {
23
23
  it('should return all tables with counts', async () => {
24
24
  const result = await repo.getAllTables();
25
25
 
26
- expect(result.length).toEqual(62);
26
+ expect(result.length).toEqual(63);
27
27
  expect(result[0]).toEqual({ name: 'agents', count: 0, type: 'BASE TABLE' });
28
28
  });
29
29
 
@@ -1,7 +1,9 @@
1
- import { timestamp } from 'drizzle-orm/pg-core';
1
+ import { timestamp, varchar } from 'drizzle-orm/pg-core';
2
2
 
3
3
  export const timestamptz = (name: string) => timestamp(name, { withTimezone: true });
4
4
 
5
+ export const varchar255 = (name: string) => varchar(name, { length: 255 });
6
+
5
7
  export const createdAt = () => timestamptz('created_at').notNull().defaultNow();
6
8
  export const updatedAt = () =>
7
9
  timestamptz('updated_at')
@@ -9,15 +9,14 @@ import {
9
9
  text,
10
10
  uniqueIndex,
11
11
  uuid,
12
- varchar,
13
12
  } from 'drizzle-orm/pg-core';
14
- import { createSelectSchema } from 'drizzle-zod';
13
+ import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
15
14
 
16
15
  import { ModelReasoning } from '@/types/message';
17
16
  import { GroundingSearch } from '@/types/search';
18
17
 
19
18
  import { idGenerator } from '../utils/idGenerator';
20
- import { timestamps } from './_helpers';
19
+ import { timestamps, varchar255 } from './_helpers';
21
20
  import { agents } from './agent';
22
21
  import { chatGroups } from './chatGroup';
23
22
  import { files } from './file';
@@ -26,6 +25,53 @@ import { sessions } from './session';
26
25
  import { threads, topics } from './topic';
27
26
  import { users } from './user';
28
27
 
28
+ /**
29
+ * Message groups table for multi-models parallel conversations
30
+ * Allows multiple AI models to respond to the same user message in parallel
31
+ */
32
+ // @ts-ignore
33
+ export const messageGroups = pgTable(
34
+ 'message_groups',
35
+ {
36
+ id: varchar255('id')
37
+ .primaryKey()
38
+ .$defaultFn(() => idGenerator('messageGroups'))
39
+ .notNull(),
40
+
41
+ // 关联关系 - 只需要 topic 层级
42
+ topicId: text('topic_id').references(() => topics.id, { onDelete: 'cascade' }),
43
+ userId: text('user_id')
44
+ .references(() => users.id, { onDelete: 'cascade' })
45
+ .notNull(),
46
+
47
+ // 支持嵌套结构
48
+ // @ts-ignore
49
+ parentGroupId: varchar255('parent_group_id').references(() => messageGroups.id, {
50
+ onDelete: 'cascade',
51
+ }),
52
+
53
+ // 关联的用户消息
54
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
55
+ parentMessageId: text('parent_message_id').references(() => messages.id, {
56
+ onDelete: 'cascade',
57
+ }),
58
+
59
+ // 元数据
60
+ title: varchar255('title'),
61
+ description: text('description'),
62
+
63
+ clientId: varchar255('client_id'),
64
+
65
+ ...timestamps,
66
+ },
67
+ (t) => [uniqueIndex('message_groups_client_id_user_id_unique').on(t.clientId, t.userId)],
68
+ );
69
+
70
+ export const insertMessageGroupSchema = createInsertSchema(messageGroups);
71
+
72
+ export type NewMessageGroup = typeof messageGroups.$inferInsert;
73
+ export type MessageGroupItem = typeof messageGroups.$inferSelect;
74
+
29
75
  // @ts-ignore
30
76
  export const messages = pgTable(
31
77
  'messages',
@@ -34,7 +80,7 @@ export const messages = pgTable(
34
80
  .$defaultFn(() => idGenerator('messages'))
35
81
  .primaryKey(),
36
82
 
37
- role: varchar('role', { length: 255 }).notNull(),
83
+ role: varchar255('role').notNull(),
38
84
  content: text('content'),
39
85
  reasoning: jsonb('reasoning').$type<ModelReasoning>(),
40
86
  search: jsonb('search').$type<GroundingSearch>(),
@@ -69,6 +115,11 @@ export const messages = pgTable(
69
115
  groupId: text('group_id').references(() => chatGroups.id, { onDelete: 'set null' }),
70
116
  // targetId can be an agent ID, "user", or null - no FK constraint
71
117
  targetId: text('target_id'),
118
+
119
+ // used for multi-models parallel
120
+ messageGroupId: varchar255('message_group_id').references(() => messageGroups.id, {
121
+ onDelete: 'cascade',
122
+ }),
72
123
  ...timestamps,
73
124
  },
74
125
  (table) => [
@@ -9,7 +9,7 @@ import { chatGroups, chatGroupsAgents } from './chatGroup';
9
9
  import { documentChunks, documents } from './document';
10
10
  import { files, knowledgeBases } from './file';
11
11
  import { generationBatches, generationTopics, generations } from './generation';
12
- import { messages, messagesFiles } from './message';
12
+ import { messageGroups, messages, messagesFiles } from './message';
13
13
  import { chunks, unstructuredChunks } from './rag';
14
14
  import { sessionGroups, sessions } from './session';
15
15
  import { threads, topicDocuments, topics } from './topic';
@@ -104,6 +104,11 @@ export const messagesRelations = relations(messages, ({ many, one }) => ({
104
104
  fields: [messages.threadId],
105
105
  references: [threads.id],
106
106
  }),
107
+
108
+ messageGroup: one(messageGroups, {
109
+ fields: [messages.messageGroupId],
110
+ references: [messageGroups.id],
111
+ }),
107
112
  }));
108
113
 
109
114
  export const agentsRelations = relations(agents, ({ many }) => ({
@@ -306,3 +311,21 @@ export const chatGroupsAgentsRelations = relations(chatGroupsAgents, ({ one }) =
306
311
  references: [users.id],
307
312
  }),
308
313
  }));
314
+
315
+ // Message Groups 相关关系定义
316
+ export const messageGroupsRelations = relations(messageGroups, ({ many, one }) => ({
317
+ user: one(users, {
318
+ fields: [messageGroups.userId],
319
+ references: [users.id],
320
+ }),
321
+ topic: one(topics, {
322
+ fields: [messageGroups.topicId],
323
+ references: [topics.id],
324
+ }),
325
+ parentGroup: one(messageGroups, {
326
+ fields: [messageGroups.parentGroupId],
327
+ references: [messageGroups.id],
328
+ }),
329
+ childGroups: many(messageGroups),
330
+ messages: many(messages),
331
+ }));
@@ -14,6 +14,7 @@ const prefixes = {
14
14
  generationTopics: 'gt',
15
15
  generations: 'gen',
16
16
  knowledgeBases: 'kb',
17
+ messageGroups: 'mg',
17
18
  messages: 'msg',
18
19
  plugins: 'plg',
19
20
  sessionGroups: 'sg',