@lobehub/chat 1.42.6 → 1.43.1

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 (140) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/changelog/v1.json +21 -0
  3. package/docs/.cdn.cache.json +1 -0
  4. package/docs/changelog/2025-01-03-user-profile.mdx +27 -0
  5. package/docs/changelog/2025-01-03-user-profile.zh-CN.mdx +26 -0
  6. package/docs/self-hosting/advanced/auth/next-auth/wechat.mdx +3 -1
  7. package/docs/self-hosting/advanced/auth/next-auth/wechat.zh-CN.mdx +2 -2
  8. package/locales/ar/auth.json +83 -4
  9. package/locales/bg-BG/auth.json +82 -3
  10. package/locales/de-DE/auth.json +85 -6
  11. package/locales/en-US/auth.json +85 -6
  12. package/locales/es-ES/auth.json +82 -3
  13. package/locales/fa-IR/auth.json +84 -5
  14. package/locales/fr-FR/auth.json +85 -6
  15. package/locales/it-IT/auth.json +83 -4
  16. package/locales/ja-JP/auth.json +83 -4
  17. package/locales/ko-KR/auth.json +82 -3
  18. package/locales/nl-NL/auth.json +83 -4
  19. package/locales/pl-PL/auth.json +83 -4
  20. package/locales/pt-BR/auth.json +83 -4
  21. package/locales/ru-RU/auth.json +82 -3
  22. package/locales/tr-TR/auth.json +82 -3
  23. package/locales/vi-VN/auth.json +82 -3
  24. package/locales/zh-CN/auth.json +82 -3
  25. package/locales/zh-TW/auth.json +82 -3
  26. package/package.json +4 -3
  27. package/src/app/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +4 -0
  28. package/src/app/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +0 -46
  29. package/src/app/(main)/(mobile)/me/(home)/features/UserBanner.tsx +11 -14
  30. package/src/app/(main)/(mobile)/me/(home)/features/useCategory.tsx +6 -21
  31. package/src/app/(main)/(mobile)/me/profile/features/Category.tsx +38 -21
  32. package/src/app/(main)/(mobile)/me/profile/layout.tsx +0 -3
  33. package/src/app/(main)/(mobile)/me/profile/page.tsx +3 -3
  34. package/src/app/(main)/chat/loading.tsx +2 -2
  35. package/src/app/(main)/discover/loading.tsx +2 -8
  36. package/src/app/(main)/files/loading.tsx +2 -2
  37. package/src/app/(main)/profile/(home)/Client.tsx +53 -0
  38. package/src/app/(main)/profile/(home)/[[...slugs]]/page.tsx +38 -0
  39. package/src/app/(main)/profile/@category/default.tsx +9 -0
  40. package/src/app/(main)/profile/@category/features/CategoryContent.tsx +38 -0
  41. package/src/app/(main)/profile/_layout/Desktop/Header.tsx +85 -0
  42. package/src/app/(main)/profile/_layout/Desktop/SideBar.tsx +42 -0
  43. package/src/app/(main)/profile/_layout/Desktop/index.tsx +48 -0
  44. package/src/app/(main)/profile/_layout/Mobile/Header.tsx +23 -5
  45. package/src/app/(main)/profile/_layout/Mobile/index.tsx +12 -5
  46. package/src/app/(main)/profile/_layout/type.ts +6 -0
  47. package/src/app/(main)/profile/error.tsx +5 -0
  48. package/src/app/(main)/profile/features/ClerkProfile.tsx +72 -0
  49. package/src/app/(main)/profile/hooks/useCategory.tsx +51 -0
  50. package/src/app/(main)/profile/layout.tsx +7 -17
  51. package/src/app/(main)/profile/loading.tsx +2 -22
  52. package/src/app/(main)/profile/not-found.tsx +3 -0
  53. package/src/app/(main)/profile/security/page.tsx +34 -0
  54. package/src/app/(main)/profile/stats/Client.tsx +52 -0
  55. package/src/app/(main)/profile/stats/features/AiHeatmaps.tsx +130 -0
  56. package/src/app/(main)/profile/stats/features/AssistantsRank.tsx +115 -0
  57. package/src/app/(main)/profile/stats/features/ModelsRank.tsx +84 -0
  58. package/src/app/(main)/profile/stats/features/ShareButton/Preview.tsx +159 -0
  59. package/src/app/(main)/profile/stats/features/ShareButton/ShareModal.tsx +87 -0
  60. package/src/app/(main)/profile/stats/features/ShareButton/TotalCard.tsx +39 -0
  61. package/src/app/(main)/profile/stats/features/ShareButton/index.tsx +26 -0
  62. package/src/app/(main)/profile/stats/features/TimeLabel.tsx +30 -0
  63. package/src/app/(main)/profile/stats/features/TopicsRank.tsx +104 -0
  64. package/src/app/(main)/profile/stats/features/TotalAssistants.tsx +56 -0
  65. package/src/app/(main)/profile/stats/features/TotalMessages.tsx +56 -0
  66. package/src/app/(main)/profile/stats/features/TotalTopics.tsx +53 -0
  67. package/src/app/(main)/profile/stats/features/TotalWords.tsx +54 -0
  68. package/src/app/(main)/profile/stats/features/Welcome.tsx +86 -0
  69. package/src/app/(main)/profile/{[[...slugs]] → stats}/page.tsx +4 -5
  70. package/src/app/(main)/repos/[id]/evals/dataset/page.tsx +2 -2
  71. package/src/app/(main)/repos/[id]/evals/evaluation/page.tsx +2 -2
  72. package/src/app/(main)/settings/@category/features/CategoryContent.tsx +1 -1
  73. package/src/app/(main)/settings/_layout/Desktop/index.tsx +1 -1
  74. package/src/app/(main)/settings/_layout/Mobile/Header.tsx +1 -1
  75. package/src/app/(main)/settings/_layout/Mobile/index.tsx +2 -0
  76. package/src/app/(main)/settings/common/features/Theme/index.tsx +2 -17
  77. package/src/app/(main)/settings/loading.tsx +2 -2
  78. package/src/components/Loading/BrandTextLoading/index.tsx +2 -2
  79. package/src/components/Statistic/index.tsx +15 -0
  80. package/src/components/StatisticCard/TitleWithPercentage.tsx +80 -0
  81. package/src/components/StatisticCard/growthPercentage.tsx +8 -0
  82. package/src/components/StatisticCard/index.tsx +209 -0
  83. package/src/const/url.ts +3 -3
  84. package/src/database/server/models/__tests__/message.test.ts +346 -35
  85. package/src/database/server/models/__tests__/session.test.ts +185 -2
  86. package/src/database/server/models/__tests__/topic.test.ts +136 -0
  87. package/src/database/server/models/__tests__/user.test.ts +140 -1
  88. package/src/database/server/models/message.ts +109 -14
  89. package/src/database/server/models/session.ts +76 -4
  90. package/src/database/server/models/topic.ts +44 -3
  91. package/src/database/server/models/user.ts +22 -0
  92. package/src/database/utils/genWhere.ts +39 -0
  93. package/src/features/ShareModal/ShareImage/index.tsx +11 -24
  94. package/src/features/ShareModal/ShareImage/type.ts +1 -6
  95. package/src/features/User/DataStatistics.tsx +21 -14
  96. package/src/features/User/UserPanel/PanelContent.tsx +12 -16
  97. package/src/features/User/UserPanel/useMenu.tsx +4 -6
  98. package/src/features/User/__tests__/PanelContent.test.tsx +4 -0
  99. package/src/features/User/__tests__/useMenu.test.tsx +1 -21
  100. package/src/hooks/useActiveTabKey.ts +34 -1
  101. package/src/{features/ShareModal/ShareImage → hooks}/useScreenshot.ts +51 -6
  102. package/src/locales/default/auth.ts +81 -2
  103. package/src/server/ld.test.ts +1 -1
  104. package/src/server/modules/AssistantStore/index.ts +3 -2
  105. package/src/server/routers/lambda/message.ts +35 -6
  106. package/src/server/routers/lambda/session.ts +17 -3
  107. package/src/server/routers/lambda/topic.ts +17 -3
  108. package/src/server/routers/lambda/user.ts +4 -0
  109. package/src/server/services/changelog/index.ts +1 -1
  110. package/src/services/message/_deprecated.ts +16 -0
  111. package/src/services/message/client.test.ts +0 -18
  112. package/src/services/message/client.ts +12 -9
  113. package/src/services/message/server.ts +12 -4
  114. package/src/services/message/type.ts +15 -3
  115. package/src/services/session/_deprecated.ts +5 -0
  116. package/src/services/session/client.ts +6 -2
  117. package/src/services/session/server.ts +6 -2
  118. package/src/services/session/type.ts +7 -1
  119. package/src/services/topic/_deprecated.ts +5 -0
  120. package/src/services/topic/client.ts +6 -2
  121. package/src/services/topic/server.ts +7 -1
  122. package/src/services/topic/type.ts +7 -2
  123. package/src/services/user/_deprecated.ts +4 -0
  124. package/src/services/user/client.ts +4 -0
  125. package/src/services/user/server.ts +4 -0
  126. package/src/services/user/type.ts +5 -0
  127. package/src/store/global/initialState.ts +6 -0
  128. package/src/store/user/slices/auth/action.test.ts +1 -33
  129. package/src/store/user/slices/auth/action.ts +0 -9
  130. package/src/store/user/slices/common/action.test.ts +2 -2
  131. package/src/types/message/index.ts +5 -0
  132. package/src/types/session/index.ts +8 -0
  133. package/src/types/topic/topic.ts +7 -0
  134. package/src/utils/format.ts +1 -1
  135. package/src/utils/time.ts +23 -0
  136. package/src/app/(main)/profile/[[...slugs]]/Client.tsx +0 -76
  137. package/src/components/Loading/BrandTextLoading/LobeChatText/SVG.tsx +0 -44
  138. package/src/components/Loading/BrandTextLoading/LobeChatText/index.tsx +0 -6
  139. package/src/components/Loading/BrandTextLoading/LobeChatText/style.css +0 -32
  140. package/src/hooks/useActiveSettingsKey.ts +0 -20
@@ -620,4 +620,140 @@ describe('TopicModel', () => {
620
620
  );
621
621
  });
622
622
  });
623
+
624
+ describe('rank', () => {
625
+ it('should return ranked topics based on message count', async () => {
626
+ // 创建测试数据
627
+ await serverDB.transaction(async (tx) => {
628
+ await tx.insert(topics).values([
629
+ { id: 'topic1', title: 'Topic 1', sessionId, userId },
630
+ { id: 'topic2', title: 'Topic 2', sessionId, userId },
631
+ { id: 'topic3', title: 'Topic 3', sessionId, userId },
632
+ ]);
633
+
634
+ // topic1 有 3 条消息
635
+ await tx.insert(messages).values([
636
+ { id: 'msg1', role: 'user', topicId: 'topic1', userId },
637
+ { id: 'msg2', role: 'assistant', topicId: 'topic1', userId },
638
+ { id: 'msg3', role: 'user', topicId: 'topic1', userId },
639
+ ]);
640
+
641
+ // topic2 有 2 条消息
642
+ await tx.insert(messages).values([
643
+ { id: 'msg4', role: 'user', topicId: 'topic2', userId },
644
+ { id: 'msg5', role: 'assistant', topicId: 'topic2', userId },
645
+ ]);
646
+
647
+ // topic3 有 1 条消息
648
+ await tx.insert(messages).values([{ id: 'msg6', role: 'user', topicId: 'topic3', userId }]);
649
+ });
650
+
651
+ // 调用 rank 方法
652
+ const result = await topicModel.rank(2);
653
+
654
+ // 断言返回结果符合预期
655
+ expect(result).toHaveLength(2);
656
+ expect(result[0]).toMatchObject({
657
+ id: 'topic1',
658
+ title: 'Topic 1',
659
+ count: 3,
660
+ sessionId,
661
+ });
662
+ expect(result[1]).toMatchObject({
663
+ id: 'topic2',
664
+ title: 'Topic 2',
665
+ count: 2,
666
+ sessionId,
667
+ });
668
+ });
669
+
670
+ it('should return empty array if no topics exist', async () => {
671
+ const result = await topicModel.rank();
672
+ expect(result).toHaveLength(0);
673
+ });
674
+
675
+ it('should respect the limit parameter', async () => {
676
+ // 创建测试数据
677
+ await serverDB.transaction(async (tx) => {
678
+ await tx.insert(topics).values([
679
+ { id: 'topic1', title: 'Topic 1', sessionId, userId },
680
+ { id: 'topic2', title: 'Topic 2', sessionId, userId },
681
+ ]);
682
+
683
+ await tx.insert(messages).values([
684
+ { id: 'msg1', role: 'user', topicId: 'topic1', userId },
685
+ { id: 'msg2', role: 'user', topicId: 'topic2', userId },
686
+ ]);
687
+ });
688
+
689
+ // 使用限制为 1 调用 rank 方法
690
+ const result = await topicModel.rank(1);
691
+
692
+ // 断言只返回一个结果
693
+ expect(result).toHaveLength(1);
694
+ });
695
+ });
696
+
697
+ describe('count with date filters', () => {
698
+ beforeEach(async () => {
699
+ // 创建测试数据
700
+ await serverDB.insert(topics).values([
701
+ {
702
+ id: 'topic1',
703
+ userId,
704
+ createdAt: new Date('2023-01-01'),
705
+ },
706
+ {
707
+ id: 'topic2',
708
+ userId,
709
+ createdAt: new Date('2023-02-01'),
710
+ },
711
+ {
712
+ id: 'topic3',
713
+ userId,
714
+ createdAt: new Date('2023-03-01'),
715
+ },
716
+ ]);
717
+ });
718
+
719
+ it('should count topics with start date filter', async () => {
720
+ const result = await topicModel.count({
721
+ startDate: '2023-02-01',
722
+ });
723
+
724
+ expect(result).toBe(2); // should count topics from Feb 1st onwards
725
+ });
726
+
727
+ it('should count topics with end date filter', async () => {
728
+ const result = await topicModel.count({
729
+ endDate: '2023-02-01',
730
+ });
731
+
732
+ expect(result).toBe(2); // should count topics up to Feb 1st
733
+ });
734
+
735
+ it('should count topics within date range', async () => {
736
+ const result = await topicModel.count({
737
+ range: ['2023-01-15', '2023-02-15'],
738
+ });
739
+
740
+ expect(result).toBe(1); // should only count topic2
741
+ });
742
+
743
+ it('should return 0 if no topics match date filters', async () => {
744
+ const result = await topicModel.count({
745
+ range: ['2024-01-01', '2024-12-31'],
746
+ });
747
+
748
+ expect(result).toBe(0);
749
+ });
750
+
751
+ it('should handle invalid date filters gracefully', async () => {
752
+ const result = await topicModel.count({
753
+ startDate: 'invalid-date',
754
+ });
755
+
756
+ expect(result).toBe(3); // should return all topics if date is invalid
757
+ });
758
+ });
623
759
  });
@@ -1,3 +1,4 @@
1
+ import dayjs from 'dayjs';
1
2
  import { eq } from 'drizzle-orm/expressions';
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
 
@@ -5,7 +6,6 @@ import { INBOX_SESSION_ID } from '@/const/session';
5
6
  import { getTestDBInstance } from '@/database/server/core/dbForTest';
6
7
  import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
7
8
  import { UserGuide, UserPreference } from '@/types/user';
8
- import { UserSettings } from '@/types/user/settings';
9
9
 
10
10
  import { UserSettingsItem, userSettings, users } from '../../../schemas';
11
11
  import { SessionModel } from '../session';
@@ -268,4 +268,143 @@ describe('UserModel', () => {
268
268
  expect(result).toEqual({});
269
269
  });
270
270
  });
271
+
272
+ describe('getUserRegistrationDuration', () => {
273
+ it('should return default values when user not found', async () => {
274
+ const duration = await userModel.getUserRegistrationDuration();
275
+ const today = dayjs().format('YYYY-MM-DD');
276
+
277
+ expect(duration).toEqual({
278
+ createdAt: today,
279
+ duration: 1,
280
+ updatedAt: today,
281
+ });
282
+ });
283
+
284
+ it('should calculate correct duration for existing user', async () => {
285
+ // Mock the current date
286
+ const now = new Date('2024-01-15');
287
+ vi.setSystemTime(now);
288
+
289
+ const createdAt = new Date('2024-01-10'); // 5 days ago
290
+ await serverDB.insert(users).values({
291
+ id: userId,
292
+ createdAt,
293
+ });
294
+
295
+ const duration = await userModel.getUserRegistrationDuration();
296
+
297
+ expect(duration).toEqual({
298
+ createdAt: '2024-01-10',
299
+ duration: 6, // 5 days difference + 1
300
+ updatedAt: '2024-01-15',
301
+ });
302
+
303
+ vi.useRealTimers();
304
+ });
305
+ });
306
+
307
+ // 补充一些边界情况的测试
308
+ describe('edge cases', () => {
309
+ describe('updatePreference', () => {
310
+ it('should handle undefined preference', async () => {
311
+ await serverDB.insert(users).values({ id: userId });
312
+
313
+ const newPreference: Partial<UserPreference> = {
314
+ guide: { topic: true },
315
+ };
316
+
317
+ await userModel.updatePreference(newPreference);
318
+
319
+ const updatedUser = await serverDB.query.users.findFirst({
320
+ where: eq(users.id, userId),
321
+ });
322
+
323
+ expect(updatedUser?.preference).toMatchObject(newPreference);
324
+ });
325
+
326
+ it('should do nothing if user not found', async () => {
327
+ const nonExistentUserModel = new UserModel(serverDB, 'non-existent-id');
328
+ const result = await nonExistentUserModel.updatePreference({ guide: { topic: true } });
329
+ expect(result).toBeUndefined();
330
+ });
331
+ });
332
+
333
+ describe('updateGuide', () => {
334
+ it('should handle undefined guide', async () => {
335
+ await serverDB.insert(users).values({
336
+ id: userId,
337
+ preference: {} as UserPreference,
338
+ });
339
+
340
+ const newGuide: Partial<UserGuide> = {
341
+ topic: true,
342
+ };
343
+
344
+ await userModel.updateGuide(newGuide);
345
+
346
+ const updatedUser = await serverDB.query.users.findFirst({
347
+ where: eq(users.id, userId),
348
+ });
349
+ expect(updatedUser?.preference).toEqual({ guide: newGuide });
350
+ });
351
+
352
+ it('should do nothing if user not found', async () => {
353
+ const nonExistentUserModel = new UserModel(serverDB, 'non-existent-id');
354
+ const result = await nonExistentUserModel.updateGuide({ topic: true });
355
+ expect(result).toBeUndefined();
356
+ });
357
+ });
358
+
359
+ describe('createUser', () => {
360
+ it('should not create duplicate user with same id', async () => {
361
+ const params = {
362
+ id: userId,
363
+ username: 'existinguser',
364
+ email: 'existing@example.com',
365
+ };
366
+
367
+ // First creation
368
+ await UserModel.createUser(serverDB, params);
369
+
370
+ // Attempt to create with same ID but different details
371
+ await UserModel.createUser(serverDB, {
372
+ ...params,
373
+ username: 'newuser',
374
+ email: 'new@example.com',
375
+ });
376
+
377
+ const user = await UserModel.findById(serverDB, userId);
378
+ expect(user?.username).toBe('existinguser');
379
+ expect(user?.email).toBe('existing@example.com');
380
+ });
381
+ });
382
+
383
+ describe('getUserState', () => {
384
+ it('should handle empty settings', async () => {
385
+ await serverDB.insert(users).values({
386
+ id: userId,
387
+ preference: {} as UserPreference,
388
+ isOnboarded: true,
389
+ });
390
+
391
+ const state = await userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults);
392
+
393
+ expect(state).toMatchObject({
394
+ userId,
395
+ isOnboarded: true,
396
+ preference: {},
397
+ settings: {
398
+ defaultAgent: {},
399
+ general: {},
400
+ keyVaults: {},
401
+ languageModel: {},
402
+ systemAgent: {},
403
+ tool: {},
404
+ tts: {},
405
+ },
406
+ });
407
+ });
408
+ });
409
+ });
271
410
  });
@@ -1,7 +1,15 @@
1
- import { count } from 'drizzle-orm';
2
- import { and, asc, desc, eq, gte, inArray, isNull, like, lt } from 'drizzle-orm/expressions';
1
+ import type { HeatmapsProps } from '@lobehub/charts';
2
+ import dayjs from 'dayjs';
3
+ import { count, sql } from 'drizzle-orm';
4
+ import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, like } from 'drizzle-orm/expressions';
3
5
 
4
6
  import { LobeChatDatabase } from '@/database/type';
7
+ import {
8
+ genEndDateWhere,
9
+ genRangeWhere,
10
+ genStartDateWhere,
11
+ genWhere,
12
+ } from '@/database/utils/genWhere';
5
13
  import { idGenerator } from '@/database/utils/idGenerator';
6
14
  import {
7
15
  ChatFileItem,
@@ -9,8 +17,10 @@ import {
9
17
  ChatTTS,
10
18
  ChatToolPayload,
11
19
  CreateMessageParams,
20
+ ModelRankItem,
12
21
  } from '@/types/message';
13
22
  import { merge } from '@/utils/merge';
23
+ import { today } from '@/utils/time';
14
24
 
15
25
  import {
16
26
  MessageItem,
@@ -265,37 +275,122 @@ export class MessageModel {
265
275
  });
266
276
  };
267
277
 
268
- count = async (): Promise<number> => {
278
+ count = async (params?: {
279
+ endDate?: string;
280
+ range?: [string, string];
281
+ startDate?: string;
282
+ }): Promise<number> => {
269
283
  const result = await this.db
270
284
  .select({
271
285
  count: count(messages.id),
272
286
  })
273
287
  .from(messages)
274
- .where(eq(messages.userId, this.userId));
288
+ .where(
289
+ genWhere([
290
+ eq(messages.userId, this.userId),
291
+ params?.range
292
+ ? genRangeWhere(params.range, messages.createdAt, (date) => date.toDate())
293
+ : undefined,
294
+ params?.endDate
295
+ ? genEndDateWhere(params.endDate, messages.createdAt, (date) => date.toDate())
296
+ : undefined,
297
+ params?.startDate
298
+ ? genStartDateWhere(params.startDate, messages.createdAt, (date) => date.toDate())
299
+ : undefined,
300
+ ]),
301
+ );
275
302
 
276
303
  return result[0].count;
277
304
  };
278
305
 
279
- countToday = async (): Promise<number> => {
280
- const today = new Date();
281
- today.setHours(0, 0, 0, 0);
282
- const tomorrow = new Date(today);
283
- tomorrow.setDate(tomorrow.getDate() + 1);
306
+ countWords = async (params?: {
307
+ endDate?: string;
308
+ range?: [string, string];
309
+ startDate?: string;
310
+ }): Promise<number> => {
311
+ const result = await this.db
312
+ .select({
313
+ count: sql<string>`sum(length(${messages.content}))`.as('total_length'),
314
+ })
315
+ .from(messages)
316
+ .where(
317
+ genWhere([
318
+ eq(messages.userId, this.userId),
319
+ params?.range
320
+ ? genRangeWhere(params.range, messages.createdAt, (date) => date.toDate())
321
+ : undefined,
322
+ params?.endDate
323
+ ? genEndDateWhere(params.endDate, messages.createdAt, (date) => date.toDate())
324
+ : undefined,
325
+ params?.startDate
326
+ ? genStartDateWhere(params.startDate, messages.createdAt, (date) => date.toDate())
327
+ : undefined,
328
+ ]),
329
+ );
330
+
331
+ return Number(result[0].count);
332
+ };
333
+
334
+ rankModels = async (limit: number = 10): Promise<ModelRankItem[]> => {
335
+ return this.db
336
+ .select({
337
+ count: count(messages.id).as('count'),
338
+ id: messages.model,
339
+ })
340
+ .from(messages)
341
+ .where(and(eq(messages.userId, this.userId), isNotNull(messages.model)))
342
+ .having(({ count }) => gt(count, 0))
343
+ .groupBy(messages.model)
344
+ .orderBy(desc(sql`count`), asc(messages.model))
345
+ .limit(limit);
346
+ };
347
+
348
+ getHeatmaps = async (): Promise<HeatmapsProps['data']> => {
349
+ const startDate = today().subtract(1, 'year').startOf('day');
350
+ const endDate = today().endOf('day');
284
351
 
285
352
  const result = await this.db
286
353
  .select({
287
354
  count: count(messages.id),
355
+ date: sql`DATE(${messages.createdAt})`.as('heatmaps_date'),
288
356
  })
289
357
  .from(messages)
290
358
  .where(
291
- and(
359
+ genWhere([
292
360
  eq(messages.userId, this.userId),
293
- gte(messages.createdAt, today),
294
- lt(messages.createdAt, tomorrow),
295
- ),
361
+ genRangeWhere(
362
+ [startDate.format('YYYY-MM-DD'), endDate.add(1, 'day').format('YYYY-MM-DD')],
363
+ messages.createdAt,
364
+ (date) => date.toDate(),
365
+ ),
366
+ ]),
367
+ )
368
+ .groupBy(sql`heatmaps_date`)
369
+ .orderBy(desc(sql`heatmaps_date`));
370
+
371
+ const heatmapData: HeatmapsProps['data'] = [];
372
+ let currentDate = startDate;
373
+
374
+ while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, 'day')) {
375
+ const formattedDate = currentDate.format('YYYY-MM-DD');
376
+ const matchingResult = result.find(
377
+ (r) => r?.date && dayjs(r.date as string).format('YYYY-MM-DD') === formattedDate,
296
378
  );
297
379
 
298
- return result[0].count;
380
+ const count = matchingResult ? matchingResult.count : 0;
381
+ const levelCount = count > 0 ? Math.floor(count / 5) : 0;
382
+ const level = levelCount > 4 ? 4 : levelCount;
383
+
384
+ heatmapData.push({
385
+ count,
386
+ date: formattedDate,
387
+ level,
388
+ });
389
+
390
+ currentDate = currentDate.add(1, 'day');
391
+ }
392
+
393
+ return heatmapData;
299
394
  };
300
395
 
301
396
  hasMoreThanN = async (n: number): Promise<boolean> => {
@@ -1,13 +1,20 @@
1
1
  import { Column, count, sql } from 'drizzle-orm';
2
- import { and, asc, desc, eq, inArray, like, not, or } from 'drizzle-orm/expressions';
2
+ import { and, asc, desc, eq, gt, inArray, isNull, like, not, or } from 'drizzle-orm/expressions';
3
3
 
4
4
  import { appEnv } from '@/config/app';
5
+ import { DEFAULT_INBOX_AVATAR } from '@/const/meta';
5
6
  import { INBOX_SESSION_ID } from '@/const/session';
6
7
  import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
7
8
  import { LobeChatDatabase } from '@/database/type';
9
+ import {
10
+ genEndDateWhere,
11
+ genRangeWhere,
12
+ genStartDateWhere,
13
+ genWhere,
14
+ } from '@/database/utils/genWhere';
8
15
  import { idGenerator } from '@/database/utils/idGenerator';
9
16
  import { parseAgentConfig } from '@/server/globalConfig/parseDefaultAgent';
10
- import { ChatSessionList, LobeAgentSession } from '@/types/session';
17
+ import { ChatSessionList, LobeAgentSession, SessionRankItem } from '@/types/session';
11
18
  import { merge } from '@/utils/merge';
12
19
 
13
20
  import {
@@ -19,6 +26,7 @@ import {
19
26
  agentsToSessions,
20
27
  sessionGroups,
21
28
  sessions,
29
+ topics,
22
30
  } from '../../schemas';
23
31
 
24
32
  export class SessionModel {
@@ -84,17 +92,81 @@ export class SessionModel {
84
92
  return { ...result, agent: (result?.agentsToSessions?.[0] as any)?.agent } as any;
85
93
  };
86
94
 
87
- count = async (): Promise<number> => {
95
+ count = async (params?: {
96
+ endDate?: string;
97
+ range?: [string, string];
98
+ startDate?: string;
99
+ }): Promise<number> => {
88
100
  const result = await this.db
89
101
  .select({
90
102
  count: count(sessions.id),
91
103
  })
92
104
  .from(sessions)
93
- .where(eq(sessions.userId, this.userId));
105
+ .where(
106
+ genWhere([
107
+ eq(sessions.userId, this.userId),
108
+ params?.range
109
+ ? genRangeWhere(params.range, sessions.createdAt, (date) => date.toDate())
110
+ : undefined,
111
+ params?.endDate
112
+ ? genEndDateWhere(params.endDate, sessions.createdAt, (date) => date.toDate())
113
+ : undefined,
114
+ params?.startDate
115
+ ? genStartDateWhere(params.startDate, sessions.createdAt, (date) => date.toDate())
116
+ : undefined,
117
+ ]),
118
+ );
94
119
 
95
120
  return result[0].count;
96
121
  };
97
122
 
123
+ _rank = async (limit: number = 10): Promise<SessionRankItem[]> => {
124
+ return this.db
125
+ .select({
126
+ avatar: agents.avatar,
127
+ backgroundColor: agents.backgroundColor,
128
+ count: count(topics.id).as('count'),
129
+ id: sessions.id,
130
+ title: agents.title,
131
+ })
132
+ .from(sessions)
133
+ .where(and(eq(sessions.userId, this.userId)))
134
+ .leftJoin(topics, eq(sessions.id, topics.sessionId))
135
+ .leftJoin(agentsToSessions, eq(sessions.id, agentsToSessions.sessionId))
136
+ .leftJoin(agents, eq(agentsToSessions.agentId, agents.id))
137
+ .groupBy(sessions.id, agentsToSessions.agentId, agents.id)
138
+ .having(({ count }) => gt(count, 0))
139
+ .orderBy(desc(sql`count`))
140
+ .limit(limit);
141
+ };
142
+
143
+ // TODO: 未来将 Inbox id 入库后可以直接使用 _rank 方法
144
+ rank = async (limit: number = 10): Promise<SessionRankItem[]> => {
145
+ const inboxResult = await this.db
146
+ .select({
147
+ count: count(topics.id).as('count'),
148
+ })
149
+ .from(topics)
150
+ .where(and(eq(topics.userId, this.userId), isNull(topics.sessionId)));
151
+
152
+ const inboxCount = inboxResult[0].count;
153
+
154
+ if (!inboxCount || inboxCount === 0) return this._rank(limit);
155
+
156
+ const result = await this._rank(limit ? limit - 1 : undefined);
157
+
158
+ return [
159
+ {
160
+ avatar: DEFAULT_INBOX_AVATAR,
161
+ backgroundColor: null,
162
+ count: inboxCount,
163
+ id: INBOX_SESSION_ID,
164
+ title: 'inbox.title',
165
+ },
166
+ ...result,
167
+ ].sort((a, b) => b.count - a.count);
168
+ };
169
+
98
170
  hasMoreThanN = async (n: number): Promise<boolean> => {
99
171
  const result = await this.db
100
172
  .select({ id: sessions.id })
@@ -1,8 +1,15 @@
1
1
  import { Column, count, sql } from 'drizzle-orm';
2
- import { and, desc, eq, exists, inArray, isNull, like, or } from 'drizzle-orm/expressions';
2
+ import { and, desc, eq, exists, gt, inArray, isNull, like, or } from 'drizzle-orm/expressions';
3
3
 
4
4
  import { LobeChatDatabase } from '@/database/type';
5
+ import {
6
+ genEndDateWhere,
7
+ genRangeWhere,
8
+ genStartDateWhere,
9
+ genWhere,
10
+ } from '@/database/utils/genWhere';
5
11
  import { idGenerator } from '@/database/utils/idGenerator';
12
+ import { TopicRankItem } from '@/types/topic';
6
13
 
7
14
  import { NewMessage, TopicItem, messages, topics } from '../../schemas';
8
15
 
@@ -92,17 +99,51 @@ export class TopicModel {
92
99
  });
93
100
  };
94
101
 
95
- count = async (): Promise<number> => {
102
+ count = async (params?: {
103
+ endDate?: string;
104
+ range?: [string, string];
105
+ startDate?: string;
106
+ }): Promise<number> => {
96
107
  const result = await this.db
97
108
  .select({
98
109
  count: count(topics.id),
99
110
  })
100
111
  .from(topics)
101
- .where(eq(topics.userId, this.userId));
112
+ .where(
113
+ genWhere([
114
+ eq(topics.userId, this.userId),
115
+ params?.range
116
+ ? genRangeWhere(params.range, topics.createdAt, (date) => date.toDate())
117
+ : undefined,
118
+ params?.endDate
119
+ ? genEndDateWhere(params.endDate, topics.createdAt, (date) => date.toDate())
120
+ : undefined,
121
+ params?.startDate
122
+ ? genStartDateWhere(params.startDate, topics.createdAt, (date) => date.toDate())
123
+ : undefined,
124
+ ]),
125
+ );
102
126
 
103
127
  return result[0].count;
104
128
  };
105
129
 
130
+ rank = async (limit: number = 10): Promise<TopicRankItem[]> => {
131
+ return this.db
132
+ .select({
133
+ count: count(messages.id).as('count'),
134
+ id: topics.id,
135
+ sessionId: topics.sessionId,
136
+ title: topics.title,
137
+ })
138
+ .from(topics)
139
+ .where(and(eq(topics.userId, this.userId)))
140
+ .leftJoin(messages, eq(topics.id, messages.topicId))
141
+ .groupBy(topics.id)
142
+ .orderBy(desc(sql`count`))
143
+ .having(({ count }) => gt(count, 0))
144
+ .limit(limit);
145
+ };
146
+
106
147
  // **************** Create *************** //
107
148
 
108
149
  create = async (
@@ -1,4 +1,5 @@
1
1
  import { TRPCError } from '@trpc/server';
2
+ import dayjs from 'dayjs';
2
3
  import { eq } from 'drizzle-orm/expressions';
3
4
  import { DeepPartial } from 'utility-types';
4
5
 
@@ -6,6 +7,7 @@ import { LobeChatDatabase } from '@/database/type';
6
7
  import { UserGuide, UserPreference } from '@/types/user';
7
8
  import { UserKeyVaults, UserSettings } from '@/types/user/settings';
8
9
  import { merge } from '@/utils/merge';
10
+ import { today } from '@/utils/time';
9
11
 
10
12
  import { NewUser, UserItem, UserSettingsItem, userSettings, users } from '../../schemas';
11
13
  import { SessionModel } from './session';
@@ -30,6 +32,26 @@ export class UserModel {
30
32
  this.db = db;
31
33
  }
32
34
 
35
+ getUserRegistrationDuration = async (): Promise<{
36
+ createdAt: string;
37
+ duration: number;
38
+ updatedAt: string;
39
+ }> => {
40
+ const user = await this.db.query.users.findFirst({ where: eq(users.id, this.userId) });
41
+ if (!user)
42
+ return {
43
+ createdAt: today().format('YYYY-MM-DD'),
44
+ duration: 1,
45
+ updatedAt: today().format('YYYY-MM-DD'),
46
+ };
47
+
48
+ return {
49
+ createdAt: dayjs(user.createdAt).format('YYYY-MM-DD'),
50
+ duration: dayjs().diff(dayjs(user.createdAt), 'day') + 1,
51
+ updatedAt: today().format('YYYY-MM-DD'),
52
+ };
53
+ };
54
+
33
55
  getUserState = async (decryptor: DecryptUserKeyVaults) => {
34
56
  const result = await this.db
35
57
  .select({