@lobehub/chat 1.42.6 → 1.43.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 (140) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/changelog/v1.json +12 -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 +76 -4
  9. package/locales/bg-BG/auth.json +75 -3
  10. package/locales/de-DE/auth.json +78 -6
  11. package/locales/en-US/auth.json +78 -6
  12. package/locales/es-ES/auth.json +75 -3
  13. package/locales/fa-IR/auth.json +77 -5
  14. package/locales/fr-FR/auth.json +78 -6
  15. package/locales/it-IT/auth.json +76 -4
  16. package/locales/ja-JP/auth.json +76 -4
  17. package/locales/ko-KR/auth.json +75 -3
  18. package/locales/nl-NL/auth.json +76 -4
  19. package/locales/pl-PL/auth.json +76 -4
  20. package/locales/pt-BR/auth.json +76 -4
  21. package/locales/ru-RU/auth.json +75 -3
  22. package/locales/tr-TR/auth.json +74 -3
  23. package/locales/vi-VN/auth.json +75 -3
  24. package/locales/zh-CN/auth.json +75 -3
  25. package/locales/zh-TW/auth.json +75 -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 +103 -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 +75 -4
  90. package/src/database/server/models/topic.ts +43 -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 +74 -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
@@ -626,8 +626,6 @@ describe('SessionModel', () => {
626
626
  });
627
627
  });
628
628
 
629
- // 在原有的 describe('SessionModel') 中添加以下测试套件
630
-
631
629
  describe('createInbox', () => {
632
630
  it('should create inbox session if not exists', async () => {
633
631
  const inbox = await sessionModel.createInbox();
@@ -782,4 +780,189 @@ describe('SessionModel', () => {
782
780
  });
783
781
  });
784
782
  });
783
+
784
+ describe('rank', () => {
785
+ it('should return ranked sessions based on topic count', async () => {
786
+ // Create test data
787
+ await serverDB.transaction(async (trx) => {
788
+ // Create sessions
789
+ await trx.insert(sessions).values([
790
+ { id: '1', userId },
791
+ { id: '2', userId },
792
+ { id: '3', userId },
793
+ ]);
794
+
795
+ // Create agents
796
+ await trx.insert(agents).values([
797
+ { id: 'a1', userId, title: 'Agent 1', avatar: 'avatar1', backgroundColor: 'bg1' },
798
+ { id: 'a2', userId, title: 'Agent 2', avatar: 'avatar2', backgroundColor: 'bg2' },
799
+ { id: 'a3', userId, title: 'Agent 3', avatar: 'avatar3', backgroundColor: 'bg3' },
800
+ ]);
801
+
802
+ // Link agents to sessions
803
+ await trx.insert(agentsToSessions).values([
804
+ { sessionId: '1', agentId: 'a1' },
805
+ { sessionId: '2', agentId: 'a2' },
806
+ { sessionId: '3', agentId: 'a3' },
807
+ ]);
808
+
809
+ // Create topics (different counts for ranking)
810
+ await trx.insert(topics).values([
811
+ { id: 't1', sessionId: '1', userId },
812
+ { id: 't2', sessionId: '1', userId },
813
+ { id: 't3', sessionId: '1', userId }, // Session 1 has 3 topics
814
+ { id: 't4', sessionId: '2', userId },
815
+ { id: 't5', sessionId: '2', userId }, // Session 2 has 2 topics
816
+ { id: 't6', sessionId: '3', userId }, // Session 3 has 1 topic
817
+ ]);
818
+ });
819
+
820
+ // Get ranked sessions with default limit
821
+ const result = await sessionModel.rank();
822
+
823
+ // Verify results
824
+ expect(result).toHaveLength(3);
825
+ // Should be ordered by topic count (descending)
826
+ expect(result[0]).toMatchObject({
827
+ id: '1',
828
+ count: 3,
829
+ title: 'Agent 1',
830
+ avatar: 'avatar1',
831
+ backgroundColor: 'bg1',
832
+ });
833
+ expect(result[1]).toMatchObject({
834
+ id: '2',
835
+ count: 2,
836
+ title: 'Agent 2',
837
+ avatar: 'avatar2',
838
+ backgroundColor: 'bg2',
839
+ });
840
+ expect(result[2]).toMatchObject({
841
+ id: '3',
842
+ count: 1,
843
+ title: 'Agent 3',
844
+ avatar: 'avatar3',
845
+ backgroundColor: 'bg3',
846
+ });
847
+ });
848
+
849
+ it('should respect the limit parameter', async () => {
850
+ // Create test data
851
+ await serverDB.transaction(async (trx) => {
852
+ // Create sessions and related data
853
+ await trx.insert(sessions).values([
854
+ { id: '1', userId },
855
+ { id: '2', userId },
856
+ { id: '3', userId },
857
+ ]);
858
+
859
+ await trx.insert(agents).values([
860
+ { id: 'a1', userId, title: 'Agent 1' },
861
+ { id: 'a2', userId, title: 'Agent 2' },
862
+ { id: 'a3', userId, title: 'Agent 3' },
863
+ ]);
864
+
865
+ await trx.insert(agentsToSessions).values([
866
+ { sessionId: '1', agentId: 'a1' },
867
+ { sessionId: '2', agentId: 'a2' },
868
+ { sessionId: '3', agentId: 'a3' },
869
+ ]);
870
+
871
+ await trx.insert(topics).values([
872
+ { id: 't1', sessionId: '1', userId },
873
+ { id: 't2', sessionId: '1', userId },
874
+ { id: 't3', sessionId: '2', userId },
875
+ { id: 't4', sessionId: '3', userId },
876
+ ]);
877
+ });
878
+
879
+ // Get ranked sessions with limit of 2
880
+ const result = await sessionModel.rank(2);
881
+
882
+ // Verify results
883
+ expect(result).toHaveLength(2);
884
+ expect(result[0].id).toBe('1'); // Most topics (2)
885
+ expect(result[1].id).toBe('2'); // Second most topics (1)
886
+ });
887
+
888
+ it('should handle sessions with no topics', async () => {
889
+ // Create test data
890
+ await serverDB.transaction(async (trx) => {
891
+ await trx.insert(sessions).values([
892
+ { id: '1', userId },
893
+ { id: '2', userId },
894
+ ]);
895
+
896
+ await trx.insert(agents).values([
897
+ { id: 'a1', userId, title: 'Agent 1' },
898
+ { id: 'a2', userId, title: 'Agent 2' },
899
+ ]);
900
+
901
+ await trx.insert(agentsToSessions).values([
902
+ { sessionId: '1', agentId: 'a1' },
903
+ { sessionId: '2', agentId: 'a2' },
904
+ ]);
905
+
906
+ // No topics created
907
+ });
908
+
909
+ const result = await sessionModel.rank();
910
+
911
+ expect(result).toHaveLength(0);
912
+ });
913
+ });
914
+
915
+ describe('hasMoreThanN', () => {
916
+ it('should return true when session count is more than N', async () => {
917
+ // Create test data
918
+ await serverDB.insert(sessions).values([
919
+ { id: '1', userId },
920
+ { id: '2', userId },
921
+ { id: '3', userId },
922
+ ]);
923
+
924
+ const result = await sessionModel.hasMoreThanN(2);
925
+ expect(result).toBe(true);
926
+ });
927
+
928
+ it('should return false when session count is equal to N', async () => {
929
+ // Create test data
930
+ await serverDB.insert(sessions).values([
931
+ { id: '1', userId },
932
+ { id: '2', userId },
933
+ ]);
934
+
935
+ const result = await sessionModel.hasMoreThanN(2);
936
+ expect(result).toBe(false);
937
+ });
938
+
939
+ it('should return false when session count is less than N', async () => {
940
+ // Create test data
941
+ await serverDB.insert(sessions).values([{ id: '1', userId }]);
942
+
943
+ const result = await sessionModel.hasMoreThanN(2);
944
+ expect(result).toBe(false);
945
+ });
946
+
947
+ it('should only count sessions for the current user', async () => {
948
+ // Create sessions for current user and another user
949
+ await serverDB.transaction(async (trx) => {
950
+ await trx.insert(users).values([{ id: 'other-user' }]);
951
+ await trx.insert(sessions).values([
952
+ { id: '1', userId }, // Current user
953
+ { id: '2', userId: 'other-user' }, // Other user
954
+ { id: '3', userId: 'other-user' }, // Other user
955
+ ]);
956
+ });
957
+
958
+ const result = await sessionModel.hasMoreThanN(1);
959
+ // Should return false as current user only has 1 session
960
+ expect(result).toBe(false);
961
+ });
962
+
963
+ it('should return false when no sessions exist', async () => {
964
+ const result = await sessionModel.hasMoreThanN(0);
965
+ expect(result).toBe(false);
966
+ });
967
+ });
785
968
  });
@@ -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> => {