@lobehub/chat 1.76.1 → 1.77.0

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 (129) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/changelog/v1.json +9 -0
  3. package/locales/ar/common.json +12 -1
  4. package/locales/ar/error.json +10 -0
  5. package/locales/ar/models.json +9 -6
  6. package/locales/ar/setting.json +28 -0
  7. package/locales/bg-BG/common.json +12 -1
  8. package/locales/bg-BG/error.json +10 -0
  9. package/locales/bg-BG/models.json +9 -6
  10. package/locales/bg-BG/setting.json +28 -0
  11. package/locales/de-DE/common.json +12 -1
  12. package/locales/de-DE/error.json +10 -0
  13. package/locales/de-DE/models.json +9 -6
  14. package/locales/de-DE/setting.json +28 -0
  15. package/locales/en-US/common.json +12 -1
  16. package/locales/en-US/error.json +10 -0
  17. package/locales/en-US/models.json +9 -6
  18. package/locales/en-US/setting.json +28 -0
  19. package/locales/es-ES/common.json +12 -1
  20. package/locales/es-ES/error.json +10 -0
  21. package/locales/es-ES/models.json +9 -6
  22. package/locales/es-ES/setting.json +28 -0
  23. package/locales/fa-IR/common.json +12 -1
  24. package/locales/fa-IR/error.json +10 -0
  25. package/locales/fa-IR/models.json +9 -6
  26. package/locales/fa-IR/setting.json +28 -0
  27. package/locales/fr-FR/common.json +12 -1
  28. package/locales/fr-FR/error.json +10 -0
  29. package/locales/fr-FR/models.json +9 -6
  30. package/locales/fr-FR/setting.json +28 -0
  31. package/locales/it-IT/common.json +12 -1
  32. package/locales/it-IT/error.json +10 -0
  33. package/locales/it-IT/models.json +9 -6
  34. package/locales/it-IT/setting.json +28 -0
  35. package/locales/ja-JP/common.json +12 -1
  36. package/locales/ja-JP/error.json +10 -0
  37. package/locales/ja-JP/models.json +9 -6
  38. package/locales/ja-JP/setting.json +28 -0
  39. package/locales/ko-KR/common.json +12 -1
  40. package/locales/ko-KR/error.json +10 -0
  41. package/locales/ko-KR/models.json +9 -6
  42. package/locales/ko-KR/setting.json +28 -0
  43. package/locales/nl-NL/common.json +12 -1
  44. package/locales/nl-NL/error.json +10 -0
  45. package/locales/nl-NL/models.json +9 -6
  46. package/locales/nl-NL/setting.json +28 -0
  47. package/locales/pl-PL/common.json +12 -1
  48. package/locales/pl-PL/error.json +10 -0
  49. package/locales/pl-PL/models.json +9 -6
  50. package/locales/pl-PL/setting.json +28 -0
  51. package/locales/pt-BR/common.json +12 -1
  52. package/locales/pt-BR/error.json +10 -0
  53. package/locales/pt-BR/models.json +9 -6
  54. package/locales/pt-BR/setting.json +28 -0
  55. package/locales/ru-RU/common.json +12 -1
  56. package/locales/ru-RU/error.json +10 -0
  57. package/locales/ru-RU/models.json +9 -6
  58. package/locales/ru-RU/setting.json +28 -0
  59. package/locales/tr-TR/common.json +12 -1
  60. package/locales/tr-TR/error.json +10 -0
  61. package/locales/tr-TR/models.json +9 -6
  62. package/locales/tr-TR/setting.json +28 -0
  63. package/locales/vi-VN/common.json +12 -1
  64. package/locales/vi-VN/error.json +10 -0
  65. package/locales/vi-VN/models.json +9 -6
  66. package/locales/vi-VN/setting.json +28 -0
  67. package/locales/zh-CN/common.json +12 -1
  68. package/locales/zh-CN/error.json +10 -0
  69. package/locales/zh-CN/models.json +9 -6
  70. package/locales/zh-CN/setting.json +28 -0
  71. package/locales/zh-TW/common.json +12 -1
  72. package/locales/zh-TW/error.json +10 -0
  73. package/locales/zh-TW/models.json +9 -6
  74. package/locales/zh-TW/setting.json +28 -0
  75. package/package.json +1 -1
  76. package/src/app/[variants]/(main)/(mobile)/me/data/features/Category.tsx +1 -1
  77. package/src/app/[variants]/(main)/chat/features/Migration/UpgradeButton.tsx +2 -1
  78. package/src/app/[variants]/(main)/settings/common/features/Common.tsx +0 -44
  79. package/src/app/[variants]/(main)/settings/hooks/useCategory.tsx +40 -14
  80. package/src/app/[variants]/(main)/settings/storage/Advanced.tsx +133 -0
  81. package/src/app/[variants]/(main)/settings/storage/IndexedDBStorage.tsx +55 -0
  82. package/src/app/[variants]/(main)/settings/storage/page.tsx +17 -0
  83. package/src/components/GroupIcon/index.tsx +25 -0
  84. package/src/components/IndexCard/index.tsx +143 -0
  85. package/src/components/ProgressItem/index.tsx +75 -0
  86. package/src/database/repositories/dataExporter/index.test.ts +330 -0
  87. package/src/database/repositories/dataExporter/index.ts +216 -0
  88. package/src/database/repositories/dataImporter/__tests__/fixtures/agents.json +65 -0
  89. package/src/database/repositories/dataImporter/__tests__/fixtures/agentsToSessions.json +541 -0
  90. package/src/database/repositories/dataImporter/__tests__/fixtures/topic.json +269 -0
  91. package/src/database/repositories/dataImporter/__tests__/fixtures/userSettings.json +18 -0
  92. package/src/database/repositories/dataImporter/__tests__/fixtures/with-client-id.json +778 -0
  93. package/src/database/repositories/dataImporter/__tests__/index.test.ts +120 -880
  94. package/src/database/repositories/dataImporter/deprecated/__tests__/index.test.ts +940 -0
  95. package/src/database/repositories/dataImporter/deprecated/index.ts +326 -0
  96. package/src/database/repositories/dataImporter/index.ts +684 -289
  97. package/src/features/DataImporter/ImportDetail.tsx +203 -0
  98. package/src/features/DataImporter/SuccessResult.tsx +22 -6
  99. package/src/features/DataImporter/_deprecated.ts +43 -0
  100. package/src/features/DataImporter/config.ts +21 -0
  101. package/src/features/DataImporter/index.tsx +112 -31
  102. package/src/features/DevPanel/PostgresViewer/DataTable/index.tsx +6 -0
  103. package/src/features/User/UserPanel/useMenu.tsx +0 -35
  104. package/src/features/User/__tests__/useMenu.test.tsx +0 -2
  105. package/src/locales/default/common.ts +11 -0
  106. package/src/locales/default/error.ts +10 -0
  107. package/src/locales/default/setting.ts +28 -0
  108. package/src/server/routers/lambda/exporter.ts +25 -0
  109. package/src/server/routers/lambda/importer.ts +19 -3
  110. package/src/server/routers/lambda/index.ts +2 -0
  111. package/src/services/config.ts +80 -135
  112. package/src/services/export/_deprecated.ts +155 -0
  113. package/src/services/export/client.ts +15 -0
  114. package/src/services/export/index.ts +6 -0
  115. package/src/services/export/server.ts +9 -0
  116. package/src/services/export/type.ts +5 -0
  117. package/src/services/import/_deprecated.ts +42 -1
  118. package/src/services/import/client.test.ts +1 -1
  119. package/src/services/import/client.ts +30 -1
  120. package/src/services/import/server.ts +70 -2
  121. package/src/services/import/type.ts +10 -0
  122. package/src/store/global/initialState.ts +1 -0
  123. package/src/types/export.ts +11 -0
  124. package/src/types/exportConfig.ts +2 -0
  125. package/src/types/importer.ts +15 -0
  126. package/src/utils/client/exportFile.ts +21 -0
  127. package/vitest.config.ts +1 -1
  128. package/src/utils/config.ts +0 -109
  129. /package/src/database/repositories/dataImporter/{__tests__ → deprecated/__tests__}/fixtures/messages.json +0 -0
@@ -1,326 +1,721 @@
1
- import { sql } from 'drizzle-orm';
2
1
  import { and, eq, inArray } from 'drizzle-orm/expressions';
3
2
 
4
- import {
5
- agents,
6
- agentsToSessions,
7
- messagePlugins,
8
- messageTranslates,
9
- messages,
10
- sessionGroups,
11
- sessions,
12
- topics,
13
- } from '@/database/schemas';
3
+ import * as EXPORT_TABLES from '@/database/schemas';
14
4
  import { LobeChatDatabase } from '@/database/type';
15
- import { ImportResult } from '@/services/config';
16
- import { ImporterEntryData } from '@/types/importer';
17
- import { sanitizeUTF8 } from '@/utils/sanitizeUTF8';
5
+ import { ImportPgDataStructure } from '@/types/export';
6
+ import { ImportResultData, ImporterEntryData } from '@/types/importer';
7
+ import { uuid } from '@/utils/uuid';
8
+
9
+ import { DeprecatedDataImporterRepos } from './deprecated';
10
+
11
+ interface ImportResult {
12
+ added: number;
13
+ errors: number;
14
+ skips: number;
15
+ updated?: number;
16
+ }
17
+
18
+ type ConflictStrategy = 'skip' | 'override' | 'merge';
19
+
20
+ interface TableImportConfig {
21
+ // 冲突处理策略
22
+ conflictStrategy?: ConflictStrategy;
23
+ // 字段处理函数
24
+ fieldProcessors?: {
25
+ [field: string]: (value: any) => any;
26
+ };
27
+ // 是否使用复合主键(没有单独的id字段)
28
+ isCompositeKey?: boolean;
29
+ // 是否保留原始ID
30
+ preserveId?: boolean;
31
+ // 关系字段定义
32
+ relations?: {
33
+ field: string;
34
+ sourceField?: string;
35
+ sourceTable: string;
36
+ }[];
37
+ // 自引用字段
38
+ selfReferences?: {
39
+ field: string;
40
+ sourceField?: string;
41
+ }[];
42
+ // 表名
43
+ table: string;
44
+ // 唯一约束字段
45
+ uniqueConstraints?: string[];
46
+ }
47
+
48
+ // 导入表配置
49
+ const IMPORT_TABLE_CONFIG: TableImportConfig[] = [
50
+ {
51
+ conflictStrategy: 'merge',
52
+ preserveId: true,
53
+ // 特殊表,ID与用户ID相同
54
+ table: 'userSettings',
55
+ uniqueConstraints: ['id'],
56
+ },
57
+ {
58
+ conflictStrategy: 'merge',
59
+ isCompositeKey: true,
60
+ table: 'userInstalledPlugins',
61
+ uniqueConstraints: ['identifier'],
62
+ },
63
+ {
64
+ conflictStrategy: 'skip',
65
+ preserveId: true,
66
+ table: 'aiProviders',
67
+ uniqueConstraints: ['id'],
68
+ },
69
+ {
70
+ conflictStrategy: 'skip',
71
+ preserveId: true, // 需要保留原始ID
72
+ relations: [
73
+ {
74
+ field: 'providerId',
75
+ sourceTable: 'aiProviders',
76
+ },
77
+ ],
78
+ table: 'aiModels',
79
+ uniqueConstraints: ['id', 'providerId'],
80
+ },
81
+ {
82
+ table: 'sessionGroups',
83
+ uniqueConstraints: [],
84
+ },
85
+ {
86
+ fieldProcessors: {
87
+ slug: (value) => (value ? `${value}-${uuid().slice(0, 8)}` : null),
88
+ },
89
+ table: 'agents',
90
+ uniqueConstraints: ['slug'],
91
+ },
92
+ {
93
+ // 对slug字段进行特殊处理
94
+ fieldProcessors: {
95
+ slug: (value) => `${value}-${uuid().slice(0, 8)}`,
96
+ },
97
+ relations: [
98
+ {
99
+ field: 'groupId',
100
+ sourceTable: 'sessionGroups',
101
+ },
102
+ ],
103
+ table: 'sessions',
104
+ uniqueConstraints: ['slug'],
105
+ },
106
+ {
107
+ relations: [
108
+ {
109
+ field: 'sessionId',
110
+ sourceTable: 'sessions',
111
+ },
112
+ ],
113
+ table: 'topics',
114
+ },
115
+ {
116
+ conflictStrategy: 'skip',
117
+ isCompositeKey: true, // 使用复合主键 [agentId, sessionId]
118
+ relations: [
119
+ {
120
+ field: 'agentId',
121
+ sourceTable: 'agents',
122
+ },
123
+ {
124
+ field: 'sessionId',
125
+ sourceTable: 'sessions',
126
+ },
127
+ ],
128
+ table: 'agentsToSessions',
129
+ uniqueConstraints: ['agentId', 'sessionId'],
130
+ },
131
+ {
132
+ relations: [
133
+ {
134
+ field: 'topicId',
135
+ sourceTable: 'topics',
136
+ },
137
+ ],
138
+ selfReferences: [
139
+ {
140
+ field: 'parentThreadId',
141
+ },
142
+ ],
143
+ table: 'threads',
144
+ },
145
+ {
146
+ relations: [
147
+ {
148
+ field: 'sessionId',
149
+ sourceTable: 'sessions',
150
+ },
151
+ {
152
+ field: 'topicId',
153
+ sourceTable: 'topics',
154
+ },
155
+ {
156
+ field: 'agentId',
157
+ sourceTable: 'agents',
158
+ },
159
+ {
160
+ field: 'threadId',
161
+ sourceTable: 'threads',
162
+ },
163
+ ],
164
+ selfReferences: [
165
+ {
166
+ field: 'parentId',
167
+ },
168
+ {
169
+ field: 'quotaId',
170
+ },
171
+ ],
172
+ table: 'messages',
173
+ },
174
+ {
175
+ conflictStrategy: 'skip',
176
+ preserveId: true, // 使用消息ID作为主键
177
+ relations: [
178
+ {
179
+ field: 'id',
180
+ sourceTable: 'messages',
181
+ },
182
+ ],
183
+ table: 'messagePlugins',
184
+ },
185
+ {
186
+ isCompositeKey: true, // 使用复合主键 [messageId, chunkId]
187
+ relations: [
188
+ {
189
+ field: 'messageId',
190
+ sourceTable: 'messages',
191
+ },
192
+ {
193
+ field: 'chunkId',
194
+ sourceTable: 'chunks',
195
+ },
196
+ ],
197
+ table: 'messageChunks',
198
+ },
199
+ {
200
+ isCompositeKey: true, // 使用复合主键 [id, queryId, chunkId]
201
+ relations: [
202
+ {
203
+ field: 'id',
204
+ sourceTable: 'messages',
205
+ },
206
+ {
207
+ field: 'queryId',
208
+ sourceTable: 'messageQueries',
209
+ },
210
+ {
211
+ field: 'chunkId',
212
+ sourceTable: 'chunks',
213
+ },
214
+ ],
215
+ table: 'messageQueryChunks',
216
+ },
217
+ // {
218
+ // relations: [
219
+ // {
220
+ // field: 'messageId',
221
+ // sourceTable: 'messages',
222
+ // },
223
+ // {
224
+ // field: 'embeddingsId',
225
+ // sourceTable: 'embeddings',
226
+ // },
227
+ // ],
228
+ // table: 'messageQueries',
229
+ // },
230
+ {
231
+ conflictStrategy: 'skip',
232
+ preserveId: true, // 使用消息ID作为主键
233
+ relations: [
234
+ {
235
+ field: 'id',
236
+ sourceTable: 'messages',
237
+ },
238
+ ],
239
+ table: 'messageTranslates',
240
+ },
241
+ // {
242
+ // conflictStrategy: 'skip',
243
+ // preserveId: true, // 使用消息ID作为主键
244
+ // relations: [
245
+ // {
246
+ // field: 'id',
247
+ // sourceTable: 'messages',
248
+ // },
249
+ // {
250
+ // field: 'fileId',
251
+ // sourceTable: 'files',
252
+ // },
253
+ // ],
254
+ // table: 'messageTTS',
255
+ // },
256
+ ];
18
257
 
19
258
  export class DataImporterRepos {
20
259
  private userId: string;
21
260
  private db: LobeChatDatabase;
22
-
23
- /**
24
- * The version of the importer that this module supports
25
- */
26
- supportVersion = 7;
261
+ private deprecatedDataImporterRepos: DeprecatedDataImporterRepos;
262
+ private idMaps: Record<string, Record<string, string>> = {};
263
+ private conflictRecords: Record<string, { field: string; value: any }[]> = {};
27
264
 
28
265
  constructor(db: LobeChatDatabase, userId: string) {
29
266
  this.userId = userId;
30
267
  this.db = db;
268
+ this.deprecatedDataImporterRepos = new DeprecatedDataImporterRepos(db, userId);
31
269
  }
32
270
 
33
- importData = async (data: ImporterEntryData) => {
34
- if (data.version > this.supportVersion) throw new Error('Unsupported version');
35
-
36
- let sessionGroupResult: ImportResult = { added: 0, errors: 0, skips: 0 };
37
- let sessionResult: ImportResult = { added: 0, errors: 0, skips: 0 };
38
- let topicResult: ImportResult = { added: 0, errors: 0, skips: 0 };
39
- let messageResult: ImportResult = { added: 0, errors: 0, skips: 0 };
40
-
41
- let sessionGroupIdMap: Record<string, string> = {};
42
- let sessionIdMap: Record<string, string> = {};
43
- let topicIdMap: Record<string, string> = {};
44
-
45
- await this.db.transaction(async (trx) => {
46
- // import sessionGroups
47
- if (data.sessionGroups && data.sessionGroups.length > 0) {
48
- const query = await trx.query.sessionGroups.findMany({
49
- where: and(
50
- eq(sessionGroups.userId, this.userId),
51
- inArray(
52
- sessionGroups.clientId,
53
- data.sessionGroups.map(({ id }) => id),
54
- ),
55
- ),
56
- });
57
-
58
- sessionGroupResult.skips = query.length;
59
-
60
- const mapArray = await trx
61
- .insert(sessionGroups)
62
- .values(
63
- data.sessionGroups.map(({ id, createdAt, updatedAt, ...res }) => ({
64
- ...res,
65
- clientId: id,
66
- createdAt: new Date(createdAt),
67
- updatedAt: new Date(updatedAt),
68
- userId: this.userId,
69
- })),
70
- )
71
- .onConflictDoUpdate({
72
- set: { updatedAt: new Date() },
73
- target: [sessionGroups.clientId, sessionGroups.userId],
74
- })
75
- .returning({ clientId: sessionGroups.clientId, id: sessionGroups.id });
76
-
77
- sessionGroupResult.added = mapArray.length - query.length;
78
-
79
- sessionGroupIdMap = Object.fromEntries(mapArray.map(({ clientId, id }) => [clientId, id]));
271
+ importData = async (data: ImporterEntryData): Promise<ImportResultData> => {
272
+ const results = await this.deprecatedDataImporterRepos.importData(data);
273
+ return { results, success: true };
274
+ };
275
+
276
+ /**
277
+ * 导入PostgreSQL数据
278
+ */
279
+ async importPgData(
280
+ dbData: ImportPgDataStructure,
281
+ conflictStrategy: ConflictStrategy = 'skip',
282
+ ): Promise<ImportResultData> {
283
+ const results: Record<string, ImportResult> = {};
284
+ const { data } = dbData;
285
+
286
+ // 初始化ID映射表和冲突记录
287
+ this.idMaps = {};
288
+ this.conflictRecords = {};
289
+
290
+ try {
291
+ await this.db.transaction(async (trx) => {
292
+ // 按配置顺序导入表
293
+ for (const config of IMPORT_TABLE_CONFIG) {
294
+ const { table: tableName } = config;
295
+
296
+ // @ts-ignore
297
+ const tableData = data[tableName];
298
+
299
+ if (!tableData || tableData.length === 0) {
300
+ continue;
301
+ }
302
+
303
+ // 使用统一的导入方法
304
+ const result = await this.importTableData(trx, config, tableData, conflictStrategy);
305
+ console.log(`imported table: ${tableName}, records: ${tableData.length}`);
306
+
307
+ if (Object.values(result).some((value) => value > 0)) {
308
+ results[tableName] = result;
309
+ }
310
+ }
311
+ });
312
+
313
+ return { results, success: true };
314
+ } catch (error) {
315
+ console.error('Import failed:', error);
316
+
317
+ return {
318
+ error: {
319
+ details: this.extractErrorDetails(error),
320
+ message: (error as any).message,
321
+ },
322
+ results,
323
+ success: false,
324
+ };
325
+ }
326
+ }
327
+
328
+ /**
329
+ * 从错误中提取详细信息
330
+ */
331
+ private extractErrorDetails(error: any) {
332
+ if (error.code === '23505') {
333
+ // PostgreSQL 唯一约束错误码
334
+ const match = error.detail?.match(/Key \((.+?)\)=\((.+?)\) already exists/);
335
+ if (match) {
336
+ return {
337
+ constraintType: 'unique',
338
+ field: match[1],
339
+ value: match[2],
340
+ };
80
341
  }
342
+ }
81
343
 
82
- // import sessions
83
- if (data.sessions && data.sessions.length > 0) {
84
- const query = await trx.query.sessions.findMany({
85
- where: and(
86
- eq(sessions.userId, this.userId),
87
- inArray(
88
- sessions.clientId,
89
- data.sessions.map(({ id }) => id),
90
- ),
91
- ),
92
- });
93
-
94
- sessionResult.skips = query.length;
95
-
96
- const mapArray = await trx
97
- .insert(sessions)
98
- .values(
99
- data.sessions.map(({ id, createdAt, updatedAt, group, ...res }) => ({
100
- ...res,
101
- clientId: id,
102
- createdAt: new Date(createdAt),
103
- groupId: group ? sessionGroupIdMap[group] : null,
104
- updatedAt: new Date(updatedAt),
105
- userId: this.userId,
106
- })),
107
- )
108
- .onConflictDoUpdate({
109
- set: { updatedAt: new Date() },
110
- target: [sessions.clientId, sessions.userId],
111
- })
112
- .returning({ clientId: sessions.clientId, id: sessions.id });
113
-
114
- // get the session client-server id map
115
- sessionIdMap = Object.fromEntries(mapArray.map(({ clientId, id }) => [clientId, id]));
116
-
117
- // update added count
118
- sessionResult.added = mapArray.length - query.length;
119
-
120
- const shouldInsertSessionAgents = data.sessions
121
- // filter out existing session, only insert new ones
122
- .filter((s) => query.every((q) => q.clientId !== s.id));
123
-
124
- // 只有当需要有新的 session 时,才会插入 agent
125
- if (shouldInsertSessionAgents.length > 0) {
126
- const agentMapArray = await trx
127
- .insert(agents)
128
- .values(
129
- shouldInsertSessionAgents.map(({ config, meta }) => ({
130
- ...config,
131
- ...meta,
132
- userId: this.userId,
133
- })),
134
- )
135
- .returning({ id: agents.id });
136
-
137
- await trx.insert(agentsToSessions).values(
138
- shouldInsertSessionAgents.map(({ id }, index) => ({
139
- agentId: agentMapArray[index].id,
140
- sessionId: sessionIdMap[id],
141
- userId: this.userId,
142
- })),
143
- );
344
+ return error.detail || 'Unknown error details';
345
+ }
346
+
347
+ /**
348
+ * 统一的表数据导入函数 - 处理所有类型的表
349
+ */
350
+ private async importTableData(
351
+ trx: any,
352
+ config: TableImportConfig,
353
+ tableData: any[],
354
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
355
+ _userConflictStrategy: ConflictStrategy,
356
+ ): Promise<ImportResult> {
357
+ const {
358
+ table: tableName,
359
+ preserveId,
360
+ isCompositeKey = false,
361
+ uniqueConstraints = [],
362
+ conflictStrategy = 'override',
363
+ fieldProcessors = {},
364
+ relations = [],
365
+ selfReferences = [],
366
+ } = config;
367
+
368
+ // @ts-ignore
369
+ const table = EXPORT_TABLES[tableName];
370
+ const result: ImportResult = { added: 0, errors: 0, skips: 0, updated: 0 };
371
+
372
+ // 初始化该表的ID映射
373
+ if (!this.idMaps[tableName]) {
374
+ this.idMaps[tableName] = {};
375
+ }
376
+
377
+ try {
378
+ // 1. 查找已存在的记录(基于clientId和userId)
379
+ let existingRecords: any[] = [];
380
+
381
+ if ('clientId' in table && 'userId' in table) {
382
+ const clientIds = tableData.map((item) => item.clientId || item.id).filter(Boolean);
383
+
384
+ if (clientIds.length > 0) {
385
+ existingRecords = await trx.query[tableName].findMany({
386
+ where: and(eq(table.userId, this.userId), inArray(table.clientId, clientIds)),
387
+ });
144
388
  }
145
389
  }
146
390
 
147
- // import topics
148
- if (data.topics && data.topics.length > 0) {
149
- const skipQuery = await trx.query.topics.findMany({
150
- where: and(
151
- eq(topics.userId, this.userId),
152
- inArray(
153
- topics.clientId,
154
- data.topics.map(({ id }) => id),
391
+ // 如果需要保留原始ID,还需要检查ID是否已存在
392
+ if (preserveId && !isCompositeKey) {
393
+ const ids = tableData.map((item) => item.id).filter(Boolean);
394
+ if (ids.length > 0) {
395
+ const idExistingRecords = await trx.query[tableName].findMany({
396
+ where: inArray(table.id, ids),
397
+ });
398
+
399
+ // 合并到已存在记录集合中
400
+ existingRecords = [
401
+ ...existingRecords,
402
+ ...idExistingRecords.filter(
403
+ (record: any) => !existingRecords.some((existing) => existing.id === record.id),
155
404
  ),
156
- ),
157
- });
158
- topicResult.skips = skipQuery.length;
159
-
160
- const mapArray = await trx
161
- .insert(topics)
162
- .values(
163
- data.topics.map(({ id, createdAt, updatedAt, sessionId, favorite, ...res }) => ({
164
- ...res,
165
- clientId: id,
166
- createdAt: new Date(createdAt),
167
- favorite: Boolean(favorite),
168
- sessionId: sessionId ? sessionIdMap[sessionId] : null,
169
- updatedAt: new Date(updatedAt),
170
- userId: this.userId,
171
- })),
172
- )
173
- .onConflictDoUpdate({
174
- set: { updatedAt: new Date() },
175
- target: [topics.clientId, topics.userId],
176
- })
177
- .returning({ clientId: topics.clientId, id: topics.id });
178
-
179
- topicIdMap = Object.fromEntries(mapArray.map(({ clientId, id }) => [clientId, id]));
180
-
181
- topicResult.added = mapArray.length - skipQuery.length;
405
+ ];
406
+ }
182
407
  }
183
408
 
184
- // import messages
185
- if (data.messages && data.messages.length > 0) {
186
- // 1. find skip ones
187
- console.time('find messages');
188
- const skipQuery = await trx.query.messages.findMany({
189
- where: and(
190
- eq(messages.userId, this.userId),
191
- inArray(
192
- messages.clientId,
193
- data.messages.map(({ id }) => id),
194
- ),
195
- ),
196
- });
197
- console.timeEnd('find messages');
198
-
199
- messageResult.skips = skipQuery.length;
200
-
201
- // filter out existing messages, only insert new ones
202
- const shouldInsertMessages = data.messages.filter((s) =>
203
- skipQuery.every((q) => q.clientId !== s.id),
204
- );
205
-
206
- // 2. insert messages
207
- if (shouldInsertMessages.length > 0) {
208
- const inertValues = shouldInsertMessages.map(
209
- ({ id, extra, createdAt, updatedAt, sessionId, topicId, content, ...res }) => ({
210
- ...res,
211
- clientId: id,
212
- content: sanitizeUTF8(content),
213
- createdAt: new Date(createdAt),
214
- model: extra?.fromModel,
215
- parentId: null,
216
- provider: extra?.fromProvider,
217
- sessionId: sessionId ? sessionIdMap[sessionId] : null,
218
- topicId: topicId ? topicIdMap[topicId] : null, // 暂时设为 NULL
219
- updatedAt: new Date(updatedAt),
220
- userId: this.userId,
221
- }),
409
+ result.skips = existingRecords.length;
410
+
411
+ // 2. 为已存在的记录建立ID映射
412
+ for (const record of existingRecords) {
413
+ // 只有非复合主键表才需要ID映射
414
+ if (!isCompositeKey) {
415
+ this.idMaps[tableName][record.id] = record.id;
416
+ if (record.clientId) {
417
+ this.idMaps[tableName][record.clientId] = record.id;
418
+ }
419
+
420
+ // 记录中可能使用的任何其他ID标识符
421
+ const originalRecord = tableData.find(
422
+ (item) => item.id === record.id || item.clientId === record.clientId,
222
423
  );
223
424
 
224
- console.time('insert messages');
225
- const BATCH_SIZE = 100; // 每批次插入的记录数
425
+ if (originalRecord) {
426
+ // 确保原始记录ID也映射到数据库记录ID
427
+ this.idMaps[tableName][originalRecord.id] = record.id;
428
+ }
429
+ }
430
+ }
431
+
432
+ // 3. 筛选出需要插入的记录
433
+ const recordsToInsert = tableData.filter(
434
+ (item) =>
435
+ !existingRecords.some(
436
+ (record) =>
437
+ (record.clientId === (item.clientId || item.id) && record.clientId) ||
438
+ (preserveId && !isCompositeKey && record.id === item.id),
439
+ ),
440
+ );
441
+
442
+ if (recordsToInsert.length === 0) {
443
+ return result;
444
+ }
445
+
446
+ // 4. 准备导入数据
447
+ const preparedData = recordsToInsert.map((item) => {
448
+ const originalId = item.id;
449
+
450
+ // 处理日期字段
451
+ const dateFields: any = {};
452
+ if (item.createdAt) dateFields.createdAt = new Date(item.createdAt);
453
+ if (item.updatedAt) dateFields.updatedAt = new Date(item.updatedAt);
454
+ if (item.accessedAt) dateFields.accessedAt = new Date(item.accessedAt);
455
+
456
+ // 创建新记录对象
457
+ let newRecord: any = {};
458
+
459
+ // 根据是否复合主键和是否保留ID决定如何处理
460
+ if (isCompositeKey) {
461
+ // 对于复合主键表,不包含id字段
462
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
463
+ const { id: _, ...rest } = item;
464
+ newRecord = {
465
+ ...rest,
466
+ ...dateFields,
467
+ clientId: item.clientId || item.id,
468
+ userId: this.userId,
469
+ };
470
+ } else {
471
+ // 非复合主键表处理
472
+ newRecord = {
473
+ ...(preserveId ? item : { ...item, id: undefined }),
474
+ ...dateFields,
475
+ clientId: item.clientId || item.id,
476
+ userId: this.userId,
477
+ };
478
+ }
226
479
 
227
- for (let i = 0; i < inertValues.length; i += BATCH_SIZE) {
228
- const batch = inertValues.slice(i, i + BATCH_SIZE);
229
- await trx.insert(messages).values(batch);
480
+ // 应用字段处理器
481
+ for (const field in fieldProcessors) {
482
+ if (newRecord[field] !== undefined) {
483
+ newRecord[field] = fieldProcessors[field](newRecord[field]);
230
484
  }
485
+ }
231
486
 
232
- console.timeEnd('insert messages');
233
-
234
- const messageIdArray = await trx
235
- .select({ clientId: messages.clientId, id: messages.id })
236
- .from(messages)
237
- .where(
238
- and(
239
- eq(messages.userId, this.userId),
240
- inArray(
241
- messages.clientId,
242
- data.messages.map(({ id }) => id),
243
- ),
244
- ),
245
- );
246
-
247
- const messageIdMap = Object.fromEntries(
248
- messageIdArray.map(({ clientId, id }) => [clientId, id]),
249
- );
487
+ // 特殊表处理
488
+ if (tableName === 'userSettings') {
489
+ newRecord.id = this.userId;
490
+ }
250
491
 
251
- // 3. update parentId for messages
252
- console.time('execute updates parentId');
253
- const parentIdUpdates = shouldInsertMessages
254
- .filter((msg) => msg.parentId) // 只处理有 parentId 的消息
255
- .map((msg) => {
256
- if (messageIdMap[msg.parentId as string])
257
- return sql`WHEN ${messages.clientId} = ${msg.id} THEN ${messageIdMap[msg.parentId as string]} `;
258
-
259
- return undefined;
260
- })
261
- .filter(Boolean);
262
-
263
- if (parentIdUpdates.length > 0) {
264
- await trx
265
- .update(messages)
266
- .set({
267
- parentId: sql`CASE ${sql.join(parentIdUpdates)} END`,
268
- })
269
- .where(
270
- inArray(
271
- messages.clientId,
272
- data.messages.map((msg) => msg.id),
273
- ),
274
- );
492
+ // 处理关系字段(外键引用)
493
+ for (const relation of relations) {
494
+ const { field, sourceTable } = relation;
495
+
496
+ if (newRecord[field] && this.idMaps[sourceTable]) {
497
+ const mappedId = this.idMaps[sourceTable][newRecord[field]];
275
498
 
276
- // if needed, you can print the sql and params
277
- // const SQL = updateQuery.toSQL();
278
- // console.log('sql:', SQL.sql);
279
- // console.log('params:', SQL.params);
499
+ if (mappedId) {
500
+ newRecord[field] = mappedId;
501
+ } else {
502
+ // 找不到映射,设为null
503
+ console.warn(
504
+ `Could not find mapped ID for ${field}=${newRecord[field]} in table ${sourceTable}`,
505
+ );
506
+ newRecord[field] = null;
507
+ }
280
508
  }
281
- console.timeEnd('execute updates parentId');
282
-
283
- // 4. insert message plugins
284
- const pluginInserts = shouldInsertMessages.filter((msg) => msg.plugin);
285
- if (pluginInserts.length > 0) {
286
- await trx.insert(messagePlugins).values(
287
- pluginInserts.map((msg) => ({
288
- apiName: msg.plugin?.apiName,
289
- arguments: msg.plugin?.arguments,
290
- id: messageIdMap[msg.id],
291
- identifier: msg.plugin?.identifier,
292
- state: msg.pluginState,
293
- toolCallId: msg.tool_call_id,
294
- type: msg.plugin?.type,
295
- userId: this.userId,
296
- })),
297
- );
509
+ }
510
+
511
+ // 简化处理自引用字段 - 直接设为null
512
+ for (const selfRef of selfReferences) {
513
+ const { field } = selfRef;
514
+ if (newRecord[field] !== undefined) {
515
+ newRecord[field] = null;
298
516
  }
517
+ }
518
+
519
+ return { newRecord, originalId };
520
+ });
299
521
 
300
- // 5. insert message translate
301
- const translateInserts = shouldInsertMessages.filter((msg) => msg.extra?.translate);
302
- if (translateInserts.length > 0) {
303
- await trx.insert(messageTranslates).values(
304
- translateInserts.map((msg) => ({
305
- id: messageIdMap[msg.id],
306
- ...msg.extra?.translate,
307
- userId: this.userId,
308
- })),
309
- );
522
+ // 5. 检查唯一约束并应用冲突策略
523
+ for (const record of preparedData) {
524
+ if (isCompositeKey && uniqueConstraints.length > 0) {
525
+ // 对于复合主键表,将所有唯一约束字段作为一个组合条件
526
+ const whereConditions = uniqueConstraints
527
+ .filter((field) => record.newRecord[field] !== undefined)
528
+ .map((field) => eq(table[field], record.newRecord[field]));
529
+
530
+ // 添加userId条件(如果表有userId字段)
531
+ if ('userId' in table) {
532
+ whereConditions.push(eq(table.userId, this.userId));
310
533
  }
311
534
 
312
- // TODO: 未来需要处理 TTS 和图片的插入 (目前存在 file 的部分,不方便处理)
535
+ if (whereConditions.length > 0) {
536
+ const exists = await trx.query[tableName].findFirst({
537
+ where: and(...whereConditions),
538
+ });
539
+
540
+ if (exists) {
541
+ // 记录冲突
542
+ if (!this.conflictRecords[tableName]) this.conflictRecords[tableName] = [];
543
+ this.conflictRecords[tableName].push({
544
+ field: uniqueConstraints.join(','),
545
+ value: uniqueConstraints
546
+ .map((field) => `${field}=${record.newRecord[field]}`)
547
+ .join(','),
548
+ });
549
+
550
+ // 应用冲突策略
551
+ switch (conflictStrategy) {
552
+ case 'skip': {
553
+ record.newRecord._skip = true;
554
+ result.skips++;
555
+
556
+ // 关键改进:即使跳过,也建立ID映射关系
557
+ if (!isCompositeKey) {
558
+ this.idMaps[tableName][record.originalId] = exists.id;
559
+ if (record.newRecord.clientId) {
560
+ this.idMaps[tableName][record.newRecord.clientId] = exists.id;
561
+ }
562
+ }
563
+ break;
564
+ }
565
+ case 'override': {
566
+ // 不需要额外操作,插入时会覆盖
567
+ break;
568
+ }
569
+ case 'merge': {
570
+ // 合并数据
571
+ await trx
572
+ .update(table)
573
+ .set(record.newRecord)
574
+ .where(and(...whereConditions));
575
+ record.newRecord._skip = true;
576
+ if (result.updated) result.updated++;
577
+ else {
578
+ result.updated = 1;
579
+ }
580
+ break;
581
+ }
582
+ }
583
+ }
584
+ }
585
+ } else {
586
+ // 处理唯一约束
587
+ for (const field of uniqueConstraints) {
588
+ if (!record.newRecord[field]) continue;
589
+
590
+ // 检查字段值是否已存在
591
+ const exists = await trx.query[tableName].findFirst({
592
+ where: eq(table[field], record.newRecord[field]),
593
+ });
594
+
595
+ if (exists) {
596
+ // 记录冲突
597
+ if (!this.conflictRecords[tableName]) this.conflictRecords[tableName] = [];
598
+ this.conflictRecords[tableName].push({
599
+ field,
600
+ value: record.newRecord[field],
601
+ });
602
+
603
+ // 应用冲突策略
604
+ switch (conflictStrategy) {
605
+ case 'skip': {
606
+ record.newRecord._skip = true;
607
+ result.skips++;
608
+
609
+ // 关键改进:即使跳过,也建立ID映射关系
610
+ if (!isCompositeKey) {
611
+ this.idMaps[tableName][record.originalId] = exists.id;
612
+ if (record.newRecord.clientId) {
613
+ this.idMaps[tableName][record.newRecord.clientId] = exists.id;
614
+ }
615
+ }
616
+ break;
617
+ }
618
+ case 'override': {
619
+ // 应用字段处理器
620
+ if (field in fieldProcessors) {
621
+ record.newRecord[field] = fieldProcessors[field](record.newRecord[field]);
622
+ }
623
+ break;
624
+ }
625
+
626
+ case 'merge': {
627
+ // 合并数据
628
+ await trx
629
+ .update(table)
630
+ .set(record.newRecord)
631
+ .where(eq(table[field], record.newRecord[field]));
632
+ record.newRecord._skip = true;
633
+ if (result.updated) result.updated++;
634
+ else {
635
+ result.updated = 1;
636
+ }
637
+ break;
638
+ }
639
+ }
640
+ }
641
+ }
313
642
  }
643
+ }
644
+
645
+ // 过滤掉标记为跳过的记录
646
+ const filteredData = preparedData.filter((record) => !record.newRecord._skip);
647
+
648
+ // 清除临时标记
649
+ filteredData.forEach((record) => delete record.newRecord._skip);
650
+
651
+ // 6. 批量插入数据
652
+ const BATCH_SIZE = 100;
653
+
654
+ for (let i = 0; i < filteredData.length; i += BATCH_SIZE) {
655
+ const batch = filteredData.slice(i, i + BATCH_SIZE).filter(Boolean);
656
+
657
+ const itemsToInsert = batch.map((item) => item.newRecord);
658
+ const originalIds = batch.map((item) => item.originalId);
659
+
660
+ try {
661
+ // 插入并返回结果
662
+ const insertQuery = trx.insert(table).values(itemsToInsert);
663
+
664
+ let insertResult;
665
+
666
+ // 只对非复合主键表需要返回ID
667
+ if (!isCompositeKey) {
668
+ const res = await insertQuery.returning();
669
+ insertResult = res.map((item: any) => ({
670
+ clientId: 'clientId' in item ? item.clientId : undefined,
671
+ id: item.id,
672
+ }));
673
+ } else {
674
+ await insertQuery;
675
+ insertResult = itemsToInsert.map(() => ({})); // 创建空结果以维持计数
676
+ }
677
+
678
+ result.added += insertResult.length;
679
+
680
+ // 建立ID映射关系 (只对非复合主键表)
681
+ if (!isCompositeKey) {
682
+ for (const [j, newRecord] of insertResult.entries()) {
683
+ const originalId = originalIds[j];
684
+ this.idMaps[tableName][originalId] = newRecord.id;
685
+
686
+ // 同时确保clientId也能映射到正确的ID
687
+ const originalRecord = tableData.find((item) => item.id === originalId);
688
+ if (originalRecord && originalRecord.clientId) {
689
+ this.idMaps[tableName][originalRecord.clientId] = newRecord.id;
690
+ }
691
+ }
692
+ }
693
+ } catch (error) {
694
+ console.error(`Error batch inserting ${tableName}:`, error);
695
+
696
+ // 处理错误并记录
697
+ if ((error as any).code === '23505') {
698
+ const match = (error as any).detail?.match(/Key \((.+?)\)=\((.+?)\) already exists/);
699
+ if (match) {
700
+ const conflictField = match[1];
701
+
702
+ if (!this.conflictRecords[tableName]) this.conflictRecords[tableName] = [];
703
+ this.conflictRecords[tableName].push({
704
+ field: conflictField,
705
+ value: match[2],
706
+ });
707
+ }
708
+ }
314
709
 
315
- messageResult.added = shouldInsertMessages.length;
710
+ result.errors += batch.length;
711
+ }
316
712
  }
317
- });
318
-
319
- return {
320
- messages: messageResult,
321
- sessionGroups: sessionGroupResult,
322
- sessions: sessionResult,
323
- topics: topicResult,
324
- };
325
- };
713
+
714
+ return result;
715
+ } catch (error) {
716
+ console.error(`Error importing table ${tableName}:`, error);
717
+ result.errors = tableData.length;
718
+ return result;
719
+ }
720
+ }
326
721
  }