@lobehub/lobehub 2.0.0-next.276 → 2.0.0-next.278

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 (98) hide show
  1. package/.cursor/rules/db-migrations.mdc +1 -1
  2. package/.cursor/rules/debug-usage.mdc +7 -5
  3. package/.cursor/rules/desktop-controller-tests.mdc +2 -1
  4. package/.cursor/rules/desktop-feature-implementation.mdc +9 -5
  5. package/.cursor/rules/desktop-local-tools-implement.mdc +67 -66
  6. package/.cursor/rules/desktop-menu-configuration.mdc +21 -9
  7. package/.cursor/rules/desktop-window-management.mdc +17 -2
  8. package/.cursor/rules/drizzle-schema-style-guide.mdc +6 -6
  9. package/.cursor/rules/hotkey.mdc +1 -0
  10. package/.cursor/rules/i18n.mdc +1 -0
  11. package/.cursor/rules/project-structure.mdc +16 -3
  12. package/.cursor/rules/react.mdc +17 -5
  13. package/.cursor/rules/recent-data-usage.mdc +2 -1
  14. package/.cursor/rules/testing-guide/testing-guide.mdc +262 -238
  15. package/.cursor/rules/testing-guide/zustand-store-action-test.mdc +1 -1
  16. package/.cursor/rules/zustand-action-patterns.mdc +1 -1
  17. package/.cursor/rules/zustand-slice-organization.mdc +4 -4
  18. package/CHANGELOG.md +51 -0
  19. package/CLAUDE.md +1 -1
  20. package/GEMINI.md +1 -1
  21. package/changelog/v1.json +14 -0
  22. package/docs/development/database-schema.dbml +16 -0
  23. package/locales/en-US/chat.json +24 -0
  24. package/locales/en-US/setting.json +11 -0
  25. package/locales/zh-CN/chat.json +24 -0
  26. package/locales/zh-CN/setting.json +11 -0
  27. package/package.json +1 -1
  28. package/packages/builtin-tool-group-agent-builder/src/client/Inspector/BatchCreateAgents/index.tsx +2 -2
  29. package/packages/builtin-tool-group-agent-builder/src/client/Inspector/UpdateGroup/index.tsx +56 -56
  30. package/packages/builtin-tool-group-agent-builder/src/client/Render/BatchCreateAgents.tsx +3 -2
  31. package/packages/builtin-tool-group-agent-builder/src/executor.ts +2 -1
  32. package/packages/business/const/src/index.ts +3 -0
  33. package/packages/database/migrations/0069_add_topic_shares_table.sql +22 -0
  34. package/packages/database/migrations/meta/0069_snapshot.json +9704 -0
  35. package/packages/database/migrations/meta/_journal.json +7 -0
  36. package/packages/database/src/models/__tests__/topicShare.test.ts +318 -0
  37. package/packages/database/src/models/topicShare.ts +177 -0
  38. package/packages/database/src/schemas/topic.ts +44 -2
  39. package/packages/types/src/agentCronJob/index.ts +19 -23
  40. package/packages/types/src/conversation.ts +5 -0
  41. package/packages/types/src/serverConfig.ts +1 -0
  42. package/packages/types/src/topic/topic.ts +46 -0
  43. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Cron/Actions.tsx +31 -0
  44. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Cron/CronTopicGroup.tsx +10 -6
  45. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Cron/index.tsx +7 -11
  46. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Cron/useDropdownMenu.tsx +102 -0
  47. package/src/app/[variants]/(main)/agent/cron/[cronId]/CronConfig.ts +179 -0
  48. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobContentEditor.tsx +111 -0
  49. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobHeader.tsx +45 -0
  50. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobSaveButton.tsx +31 -0
  51. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobScheduleConfig.tsx +213 -0
  52. package/src/app/[variants]/(main)/agent/cron/[cronId]/index.tsx +186 -344
  53. package/src/app/[variants]/(main)/agent/features/Conversation/Header/ShareButton/index.tsx +24 -9
  54. package/src/app/[variants]/(main)/agent/profile/features/AgentCronJobs/index.tsx +42 -97
  55. package/src/app/[variants]/(main)/agent/profile/features/ProfileEditor/index.tsx +4 -20
  56. package/src/app/[variants]/(main)/community/features/UserAvatar/index.tsx +15 -5
  57. package/src/app/[variants]/(main)/group/_layout/Sidebar/GroupConfig/AgentProfilePopup.tsx +1 -6
  58. package/src/app/[variants]/(main)/group/features/Conversation/Header/ShareButton/index.tsx +26 -9
  59. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/AspectRatioSelect/index.tsx +1 -2
  60. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/ImageNum.tsx +54 -173
  61. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/ResolutionSelect.tsx +22 -67
  62. package/src/app/[variants]/(mobile)/router/mobileRouter.config.tsx +18 -0
  63. package/src/app/[variants]/router/desktopRouter.config.tsx +18 -0
  64. package/src/app/[variants]/share/t/[id]/SharedMessageList.tsx +54 -0
  65. package/src/app/[variants]/share/t/[id]/_layout/index.tsx +170 -0
  66. package/src/app/[variants]/share/t/[id]/features/Portal/index.tsx +66 -0
  67. package/src/app/[variants]/share/t/[id]/index.tsx +112 -0
  68. package/src/app/robots.tsx +1 -1
  69. package/src/business/client/BusinessMobileRoutes.tsx +1 -1
  70. package/src/features/Conversation/ChatList/index.tsx +12 -5
  71. package/src/features/Conversation/Messages/AssistantGroup/Tool/Render/index.tsx +8 -4
  72. package/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx +15 -10
  73. package/src/features/Conversation/Messages/AssistantGroup/Tools.tsx +3 -1
  74. package/src/features/Conversation/Messages/AssistantGroup/components/ContentBlock.tsx +3 -2
  75. package/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx +2 -2
  76. package/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx +25 -26
  77. package/src/features/Conversation/Messages/Supervisor/components/Group.tsx +4 -2
  78. package/src/features/Conversation/Messages/Tool/Tool/index.tsx +16 -12
  79. package/src/features/Conversation/Messages/Tool/index.tsx +20 -11
  80. package/src/features/Conversation/Messages/index.tsx +1 -1
  81. package/src/features/Conversation/store/slices/data/action.ts +2 -1
  82. package/src/features/SharePopover/index.tsx +215 -0
  83. package/src/features/SharePopover/style.ts +10 -0
  84. package/src/libs/next/proxy/define-config.ts +4 -1
  85. package/src/locales/default/chat.ts +26 -0
  86. package/src/proxy.ts +1 -0
  87. package/src/server/globalConfig/index.ts +1 -0
  88. package/src/server/routers/lambda/__tests__/message.test.ts +152 -0
  89. package/src/server/routers/lambda/__tests__/share.test.ts +227 -0
  90. package/src/server/routers/lambda/__tests__/topic.test.ts +174 -0
  91. package/src/server/routers/lambda/index.ts +2 -0
  92. package/src/server/routers/lambda/message.ts +37 -4
  93. package/src/server/routers/lambda/share.ts +55 -0
  94. package/src/server/routers/lambda/topic.ts +45 -0
  95. package/src/services/chatGroup/index.ts +1 -4
  96. package/src/services/message/index.ts +1 -0
  97. package/src/services/topic/index.ts +16 -0
  98. package/src/store/serverConfig/selectors.ts +1 -0
@@ -483,6 +483,13 @@
483
483
  "when": 1768189437504,
484
484
  "tag": "0068_update_group_data",
485
485
  "breakpoints": true
486
+ },
487
+ {
488
+ "idx": 69,
489
+ "version": "7",
490
+ "when": 1768303764632,
491
+ "tag": "0069_add_topic_shares_table",
492
+ "breakpoints": true
486
493
  }
487
494
  ],
488
495
  "version": "6"
@@ -0,0 +1,318 @@
1
+ // @vitest-environment node
2
+ import { TRPCError } from '@trpc/server';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+
5
+ import { getTestDB } from '../../core/getTestDB';
6
+ import { agents, sessions, topicShares, topics, users } from '../../schemas';
7
+ import { LobeChatDatabase } from '../../type';
8
+ import { TopicShareModel } from '../topicShare';
9
+
10
+ const serverDB: LobeChatDatabase = await getTestDB();
11
+
12
+ const userId = 'topic-share-test-user-id';
13
+ const userId2 = 'topic-share-test-user-id-2';
14
+ const sessionId = 'topic-share-test-session';
15
+ const topicId = 'topic-share-test-topic';
16
+ const topicId2 = 'topic-share-test-topic-2';
17
+ const agentId = 'topic-share-test-agent';
18
+
19
+ const topicShareModel = new TopicShareModel(serverDB, userId);
20
+ const topicShareModel2 = new TopicShareModel(serverDB, userId2);
21
+
22
+ describe('TopicShareModel', () => {
23
+ beforeEach(async () => {
24
+ await serverDB.delete(users);
25
+
26
+ // Create test users, sessions, agents and topics
27
+ await serverDB.transaction(async (tx) => {
28
+ await tx.insert(users).values([{ id: userId }, { id: userId2 }]);
29
+ await tx.insert(sessions).values([
30
+ { id: sessionId, userId },
31
+ { id: `${sessionId}-2`, userId: userId2 },
32
+ ]);
33
+ await tx.insert(agents).values([{ id: agentId, userId }]);
34
+ await tx.insert(topics).values([
35
+ { id: topicId, sessionId, userId, agentId, title: 'Test Topic' },
36
+ { id: topicId2, sessionId, userId, title: 'Test Topic 2' },
37
+ { id: 'user2-topic', sessionId: `${sessionId}-2`, userId: userId2, title: 'User 2 Topic' },
38
+ ]);
39
+ });
40
+ });
41
+
42
+ afterEach(async () => {
43
+ await serverDB.delete(topicShares);
44
+ await serverDB.delete(topics);
45
+ await serverDB.delete(agents);
46
+ await serverDB.delete(sessions);
47
+ await serverDB.delete(users);
48
+ });
49
+
50
+ describe('create', () => {
51
+ it('should create a share for a topic with default visibility', async () => {
52
+ const result = await topicShareModel.create(topicId);
53
+
54
+ expect(result).toBeDefined();
55
+ expect(result.topicId).toBe(topicId);
56
+ expect(result.userId).toBe(userId);
57
+ expect(result.visibility).toBe('private');
58
+ expect(result.id).toBeDefined();
59
+ });
60
+
61
+ it('should create a share with link visibility', async () => {
62
+ const result = await topicShareModel.create(topicId, 'link');
63
+
64
+ expect(result.visibility).toBe('link');
65
+ });
66
+
67
+ it('should throw error when topic does not exist', async () => {
68
+ await expect(topicShareModel.create('non-existent-topic')).rejects.toThrow(
69
+ 'Topic not found or not owned by user',
70
+ );
71
+ });
72
+
73
+ it('should throw error when trying to share another users topic', async () => {
74
+ await expect(topicShareModel.create('user2-topic')).rejects.toThrow(
75
+ 'Topic not found or not owned by user',
76
+ );
77
+ });
78
+ });
79
+
80
+ describe('updateVisibility', () => {
81
+ it('should update share visibility', async () => {
82
+ await topicShareModel.create(topicId, 'private');
83
+
84
+ const result = await topicShareModel.updateVisibility(topicId, 'link');
85
+
86
+ expect(result).toBeDefined();
87
+ expect(result!.visibility).toBe('link');
88
+ });
89
+
90
+ it('should return null when share does not exist', async () => {
91
+ const result = await topicShareModel.updateVisibility('non-existent-topic', 'link');
92
+
93
+ expect(result).toBeNull();
94
+ });
95
+
96
+ it('should not update other users share', async () => {
97
+ // Create share for user2
98
+ await topicShareModel2.create('user2-topic', 'private');
99
+
100
+ // User1 tries to update user2's share
101
+ const result = await topicShareModel.updateVisibility('user2-topic', 'link');
102
+
103
+ expect(result).toBeNull();
104
+
105
+ // Verify user2's share is unchanged
106
+ const share = await topicShareModel2.getByTopicId('user2-topic');
107
+ expect(share!.visibility).toBe('private');
108
+ });
109
+ });
110
+
111
+ describe('deleteByTopicId', () => {
112
+ it('should delete share by topic id', async () => {
113
+ await topicShareModel.create(topicId);
114
+
115
+ await topicShareModel.deleteByTopicId(topicId);
116
+
117
+ const share = await topicShareModel.getByTopicId(topicId);
118
+ expect(share).toBeNull();
119
+ });
120
+
121
+ it('should not delete other users share', async () => {
122
+ await topicShareModel2.create('user2-topic');
123
+
124
+ // User1 tries to delete user2's share
125
+ await topicShareModel.deleteByTopicId('user2-topic');
126
+
127
+ // User2's share should still exist
128
+ const share = await topicShareModel2.getByTopicId('user2-topic');
129
+ expect(share).not.toBeNull();
130
+ });
131
+ });
132
+
133
+ describe('getByTopicId', () => {
134
+ it('should get share info by topic id', async () => {
135
+ const created = await topicShareModel.create(topicId, 'link');
136
+
137
+ const result = await topicShareModel.getByTopicId(topicId);
138
+
139
+ expect(result).toBeDefined();
140
+ expect(result!.id).toBe(created.id);
141
+ expect(result!.topicId).toBe(topicId);
142
+ expect(result!.visibility).toBe('link');
143
+ });
144
+
145
+ it('should return null when share does not exist', async () => {
146
+ const result = await topicShareModel.getByTopicId(topicId);
147
+
148
+ expect(result).toBeNull();
149
+ });
150
+
151
+ it('should not return other users share', async () => {
152
+ await topicShareModel2.create('user2-topic');
153
+
154
+ const result = await topicShareModel.getByTopicId('user2-topic');
155
+
156
+ expect(result).toBeNull();
157
+ });
158
+ });
159
+
160
+ describe('findByShareId (static)', () => {
161
+ it('should find share by share id with topic and agent info', async () => {
162
+ const created = await topicShareModel.create(topicId, 'link');
163
+
164
+ const result = await TopicShareModel.findByShareId(serverDB, created.id);
165
+
166
+ expect(result).toBeDefined();
167
+ expect(result!.shareId).toBe(created.id);
168
+ expect(result!.topicId).toBe(topicId);
169
+ expect(result!.title).toBe('Test Topic');
170
+ expect(result!.ownerId).toBe(userId);
171
+ expect(result!.visibility).toBe('link');
172
+ expect(result!.agentId).toBe(agentId);
173
+ });
174
+
175
+ it('should return null when share does not exist', async () => {
176
+ const result = await TopicShareModel.findByShareId(serverDB, 'non-existent-share');
177
+
178
+ expect(result).toBeNull();
179
+ });
180
+
181
+ it('should return share without agent info when topic has no agent', async () => {
182
+ const created = await topicShareModel.create(topicId2);
183
+
184
+ const result = await TopicShareModel.findByShareId(serverDB, created.id);
185
+
186
+ expect(result).toBeDefined();
187
+ expect(result!.agentId).toBeNull();
188
+ });
189
+ });
190
+
191
+ describe('incrementPageViewCount (static)', () => {
192
+ it('should increment page view count', async () => {
193
+ const created = await topicShareModel.create(topicId);
194
+
195
+ // Initial page view count is 0
196
+ const initial = await serverDB.query.topicShares.findFirst({
197
+ where: (t, { eq }) => eq(t.id, created.id),
198
+ });
199
+ expect(initial!.pageViewCount).toBe(0);
200
+
201
+ // Increment page view count
202
+ await TopicShareModel.incrementPageViewCount(serverDB, created.id);
203
+
204
+ const after = await serverDB.query.topicShares.findFirst({
205
+ where: (t, { eq }) => eq(t.id, created.id),
206
+ });
207
+ expect(after!.pageViewCount).toBe(1);
208
+ });
209
+
210
+ it('should increment page view count multiple times', async () => {
211
+ const created = await topicShareModel.create(topicId);
212
+
213
+ await TopicShareModel.incrementPageViewCount(serverDB, created.id);
214
+ await TopicShareModel.incrementPageViewCount(serverDB, created.id);
215
+ await TopicShareModel.incrementPageViewCount(serverDB, created.id);
216
+
217
+ const result = await serverDB.query.topicShares.findFirst({
218
+ where: (t, { eq }) => eq(t.id, created.id),
219
+ });
220
+ expect(result!.pageViewCount).toBe(3);
221
+ });
222
+ });
223
+
224
+ describe('findByShareIdWithAccessCheck (static)', () => {
225
+ it('should return share for owner regardless of visibility', async () => {
226
+ const created = await topicShareModel.create(topicId, 'private');
227
+
228
+ const result = await TopicShareModel.findByShareIdWithAccessCheck(
229
+ serverDB,
230
+ created.id,
231
+ userId,
232
+ );
233
+
234
+ expect(result).toBeDefined();
235
+ expect(result.shareId).toBe(created.id);
236
+ });
237
+
238
+ it('should return share for anonymous user when visibility is link', async () => {
239
+ const created = await topicShareModel.create(topicId, 'link');
240
+
241
+ const result = await TopicShareModel.findByShareIdWithAccessCheck(
242
+ serverDB,
243
+ created.id,
244
+ undefined,
245
+ );
246
+
247
+ expect(result).toBeDefined();
248
+ expect(result.shareId).toBe(created.id);
249
+ });
250
+
251
+ it('should throw NOT_FOUND when share does not exist', async () => {
252
+ await expect(
253
+ TopicShareModel.findByShareIdWithAccessCheck(serverDB, 'non-existent', userId),
254
+ ).rejects.toThrow(TRPCError);
255
+
256
+ try {
257
+ await TopicShareModel.findByShareIdWithAccessCheck(serverDB, 'non-existent', userId);
258
+ } catch (error) {
259
+ expect((error as TRPCError).code).toBe('NOT_FOUND');
260
+ }
261
+ });
262
+
263
+ it('should throw FORBIDDEN when visibility is private and user is not owner', async () => {
264
+ const created = await topicShareModel.create(topicId, 'private');
265
+
266
+ await expect(
267
+ TopicShareModel.findByShareIdWithAccessCheck(serverDB, created.id, userId2),
268
+ ).rejects.toThrow(TRPCError);
269
+
270
+ try {
271
+ await TopicShareModel.findByShareIdWithAccessCheck(serverDB, created.id, userId2);
272
+ } catch (error) {
273
+ expect((error as TRPCError).code).toBe('FORBIDDEN');
274
+ }
275
+ });
276
+
277
+ it('should throw FORBIDDEN when visibility is private and user is anonymous', async () => {
278
+ const created = await topicShareModel.create(topicId, 'private');
279
+
280
+ await expect(
281
+ TopicShareModel.findByShareIdWithAccessCheck(serverDB, created.id, undefined),
282
+ ).rejects.toThrow(TRPCError);
283
+
284
+ try {
285
+ await TopicShareModel.findByShareIdWithAccessCheck(serverDB, created.id, undefined);
286
+ } catch (error) {
287
+ expect((error as TRPCError).code).toBe('FORBIDDEN');
288
+ }
289
+ });
290
+ });
291
+
292
+ describe('user isolation', () => {
293
+ it('should enforce user data isolation for all operations', async () => {
294
+ // User1 creates a share
295
+ await topicShareModel.create(topicId, 'private');
296
+
297
+ // User2 creates a share
298
+ await topicShareModel2.create('user2-topic', 'link');
299
+
300
+ // User1 cannot access user2's share via getByTopicId
301
+ const user1Access = await topicShareModel.getByTopicId('user2-topic');
302
+ expect(user1Access).toBeNull();
303
+
304
+ // User2 cannot access user1's share via getByTopicId
305
+ const user2Access = await topicShareModel2.getByTopicId(topicId);
306
+ expect(user2Access).toBeNull();
307
+
308
+ // User1 cannot update user2's share
309
+ const updateResult = await topicShareModel.updateVisibility('user2-topic', 'private');
310
+ expect(updateResult).toBeNull();
311
+
312
+ // User1 cannot delete user2's share
313
+ await topicShareModel.deleteByTopicId('user2-topic');
314
+ const stillExists = await topicShareModel2.getByTopicId('user2-topic');
315
+ expect(stillExists).not.toBeNull();
316
+ });
317
+ });
318
+ });
@@ -0,0 +1,177 @@
1
+ import type { ShareVisibility } from '@lobechat/types';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { and, asc, eq, sql } from 'drizzle-orm';
4
+
5
+ import { agents, chatGroups, chatGroupsAgents, topicShares, topics } from '../schemas';
6
+ import { LobeChatDatabase } from '../type';
7
+
8
+ export type TopicShareData = NonNullable<
9
+ Awaited<ReturnType<(typeof TopicShareModel)['findByShareId']>>
10
+ >;
11
+
12
+ export class TopicShareModel {
13
+ private userId: string;
14
+ private db: LobeChatDatabase;
15
+
16
+ constructor(db: LobeChatDatabase, userId: string) {
17
+ this.userId = userId;
18
+ this.db = db;
19
+ }
20
+
21
+ /**
22
+ * Create a new share for a topic.
23
+ * Each topic can only have one share record (enforced by unique constraint).
24
+ */
25
+ create = async (topicId: string, visibility: ShareVisibility = 'private') => {
26
+ // First verify the topic belongs to the user
27
+ const topic = await this.db.query.topics.findFirst({
28
+ where: and(eq(topics.id, topicId), eq(topics.userId, this.userId)),
29
+ });
30
+
31
+ if (!topic) {
32
+ throw new Error('Topic not found or not owned by user');
33
+ }
34
+
35
+ const [result] = await this.db
36
+ .insert(topicShares)
37
+ .values({
38
+ topicId,
39
+ userId: this.userId,
40
+ visibility,
41
+ })
42
+ .returning();
43
+
44
+ return result;
45
+ };
46
+
47
+ /**
48
+ * Update share visibility
49
+ */
50
+ updateVisibility = async (topicId: string, visibility: ShareVisibility) => {
51
+ const [result] = await this.db
52
+ .update(topicShares)
53
+ .set({ updatedAt: new Date(), visibility })
54
+ .where(and(eq(topicShares.topicId, topicId), eq(topicShares.userId, this.userId)))
55
+ .returning();
56
+
57
+ return result || null;
58
+ };
59
+
60
+ /**
61
+ * Delete a share by topic ID
62
+ */
63
+ deleteByTopicId = async (topicId: string) => {
64
+ return this.db
65
+ .delete(topicShares)
66
+ .where(and(eq(topicShares.topicId, topicId), eq(topicShares.userId, this.userId)));
67
+ };
68
+
69
+ /**
70
+ * Get share info by topic ID (for the owner)
71
+ */
72
+ getByTopicId = async (topicId: string) => {
73
+ const result = await this.db
74
+ .select({
75
+ id: topicShares.id,
76
+ topicId: topicShares.topicId,
77
+ visibility: topicShares.visibility,
78
+ })
79
+ .from(topicShares)
80
+ .where(and(eq(topicShares.topicId, topicId), eq(topicShares.userId, this.userId)))
81
+ .limit(1);
82
+
83
+ return result[0] || null;
84
+ };
85
+
86
+ /**
87
+ * Find shared topic by share ID.
88
+ * Returns share info including ownerId for permission checking by caller.
89
+ */
90
+ static findByShareId = async (db: LobeChatDatabase, shareId: string) => {
91
+ const result = await db
92
+ .select({
93
+ agentAvatar: agents.avatar,
94
+ agentBackgroundColor: agents.backgroundColor,
95
+ agentId: topics.agentId,
96
+ agentMarketIdentifier: agents.marketIdentifier,
97
+ agentSlug: agents.slug,
98
+ agentTitle: agents.title,
99
+ groupAvatar: chatGroups.avatar,
100
+ groupBackgroundColor: chatGroups.backgroundColor,
101
+ groupId: topics.groupId,
102
+ groupTitle: chatGroups.title,
103
+ ownerId: topicShares.userId,
104
+ shareId: topicShares.id,
105
+ title: topics.title,
106
+ topicId: topics.id,
107
+ visibility: topicShares.visibility,
108
+ })
109
+ .from(topicShares)
110
+ .innerJoin(topics, eq(topicShares.topicId, topics.id))
111
+ .leftJoin(agents, eq(topics.agentId, agents.id))
112
+ .leftJoin(chatGroups, eq(topics.groupId, chatGroups.id))
113
+ .where(eq(topicShares.id, shareId))
114
+ .limit(1);
115
+
116
+ if (!result[0]) return null;
117
+
118
+ const share = result[0];
119
+
120
+ // Fetch group members if this is a group topic
121
+ let groupMembers: { avatar: string | null; backgroundColor: string | null }[] | undefined;
122
+ if (share.groupId) {
123
+ const members = await db
124
+ .select({
125
+ avatar: agents.avatar,
126
+ backgroundColor: agents.backgroundColor,
127
+ })
128
+ .from(chatGroupsAgents)
129
+ .innerJoin(agents, eq(chatGroupsAgents.agentId, agents.id))
130
+ .where(eq(chatGroupsAgents.chatGroupId, share.groupId))
131
+ .orderBy(asc(chatGroupsAgents.order))
132
+ .limit(4);
133
+
134
+ groupMembers = members;
135
+ }
136
+
137
+ return { ...share, groupMembers };
138
+ };
139
+
140
+ /**
141
+ * Increment page view count for a share.
142
+ * Should be called after permission check passes.
143
+ */
144
+ static incrementPageViewCount = async (db: LobeChatDatabase, shareId: string) => {
145
+ await db
146
+ .update(topicShares)
147
+ .set({ pageViewCount: sql`${topicShares.pageViewCount} + 1` })
148
+ .where(eq(topicShares.id, shareId));
149
+ };
150
+
151
+ /**
152
+ * Find shared topic by share ID with visibility check.
153
+ * Throws TRPCError if access is denied.
154
+ */
155
+ static findByShareIdWithAccessCheck = async (
156
+ db: LobeChatDatabase,
157
+ shareId: string,
158
+ accessUserId?: string,
159
+ ): Promise<TopicShareData> => {
160
+ const share = await TopicShareModel.findByShareId(db, shareId);
161
+
162
+ if (!share) {
163
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Share not found' });
164
+ }
165
+
166
+ const isOwner = accessUserId && share.ownerId === accessUserId;
167
+
168
+ // Only check visibility for non-owners
169
+ // 'private' - only owner can view
170
+ // 'link' - anyone with the link can view
171
+ if (!isOwner && share.visibility === 'private') {
172
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'This share is private' });
173
+ }
174
+
175
+ return share;
176
+ };
177
+ }
@@ -1,10 +1,19 @@
1
1
  /* eslint-disable sort-keys-fix/sort-keys-fix */
2
2
  import type { ChatTopicMetadata, ThreadMetadata } from '@lobechat/types';
3
3
  import { sql } from 'drizzle-orm';
4
- import { boolean, index, jsonb, pgTable, primaryKey, text, uniqueIndex } from 'drizzle-orm/pg-core';
4
+ import {
5
+ boolean,
6
+ index,
7
+ integer,
8
+ jsonb,
9
+ pgTable,
10
+ primaryKey,
11
+ text,
12
+ uniqueIndex,
13
+ } from 'drizzle-orm/pg-core';
5
14
  import { createInsertSchema } from 'drizzle-zod';
6
15
 
7
- import { idGenerator } from '../utils/idGenerator';
16
+ import { createNanoId, idGenerator } from '../utils/idGenerator';
8
17
  import { createdAt, timestamps, timestamptz } from './_helpers';
9
18
  import { agents } from './agent';
10
19
  import { chatGroups } from './chatGroup';
@@ -133,3 +142,36 @@ export const topicDocuments = pgTable(
133
142
 
134
143
  export type NewTopicDocument = typeof topicDocuments.$inferInsert;
135
144
  export type TopicDocumentItem = typeof topicDocuments.$inferSelect;
145
+
146
+ /**
147
+ * Topic sharing table - Manages public sharing links for topics
148
+ */
149
+ export const topicShares = pgTable(
150
+ 'topic_shares',
151
+ {
152
+ id: text('id')
153
+ .$defaultFn(() => createNanoId(8)())
154
+ .primaryKey(),
155
+
156
+ topicId: text('topic_id')
157
+ .notNull()
158
+ .references(() => topics.id, { onDelete: 'cascade' }),
159
+
160
+ userId: text('user_id')
161
+ .references(() => users.id, { onDelete: 'cascade' })
162
+ .notNull(),
163
+
164
+ visibility: text('visibility').default('private').notNull(), // 'private' | 'link'
165
+
166
+ pageViewCount: integer('page_view_count').default(0).notNull(),
167
+
168
+ ...timestamps,
169
+ },
170
+ (t) => [
171
+ uniqueIndex('topic_shares_topic_id_unique').on(t.topicId),
172
+ index('topic_shares_user_id_idx').on(t.userId),
173
+ ],
174
+ );
175
+
176
+ export type NewTopicShare = typeof topicShares.$inferInsert;
177
+ export type TopicShareItem = typeof topicShares.$inferSelect;
@@ -21,25 +21,6 @@ export const cronPatternSchema = z
21
21
  // Minimum 30 minutes validation (using standard cron format)
22
22
  export const minimumIntervalSchema = z.string().refine((pattern) => {
23
23
  // Standard cron format: minute hour day month weekday
24
- const allowedPatterns = [
25
- '*/30 * * * *', // Every 30 minutes
26
- '0 * * * *', // Every hour
27
- '0 */2 * * *', // Every 2 hours
28
- '0 */3 * * *', // Every 3 hours
29
- '0 */4 * * *', // Every 4 hours
30
- '0 */6 * * *', // Every 6 hours
31
- '0 */8 * * *', // Every 8 hours
32
- '0 */12 * * *', // Every 12 hours
33
- '0 0 * * *', // Daily at midnight
34
- '0 0 * * 0', // Weekly on Sunday
35
- '0 0 1 * *', // Monthly on 1st
36
- ];
37
-
38
- // Check if it matches allowed patterns
39
- if (allowedPatterns.includes(pattern)) {
40
- return true;
41
- }
42
-
43
24
  // Parse pattern to validate minimum 30-minute interval
44
25
  const parts = pattern.split(' ');
45
26
  if (parts.length !== 5) {
@@ -48,24 +29,39 @@ export const minimumIntervalSchema = z.string().refine((pattern) => {
48
29
 
49
30
  const [minute, hour] = parts;
50
31
 
32
+ // Validate minute is 0 or 30 (we only allow 30-minute intervals)
33
+ const isValidMinute = minute === '0' || minute === '30' || minute === '*/30';
34
+
35
+ if (!isValidMinute) {
36
+ return false;
37
+ }
38
+
51
39
  // Allow minute intervals >= 30 (e.g., */30, */45, */60)
52
40
  if (minute.startsWith('*/')) {
53
41
  const interval = parseInt(minute.slice(2));
54
42
  if (!isNaN(interval) && interval >= 30) {
55
43
  return true;
56
44
  }
45
+ return false;
57
46
  }
58
47
 
59
- // Allow hourly patterns: 0 */N * * * where N >= 1
60
- if (minute === '0' && hour.startsWith('*/')) {
48
+ // Allow hourly patterns: {0|30} */N * * * where N >= 1
49
+ if ((minute === '0' || minute === '30') && hour.startsWith('*/')) {
61
50
  const interval = parseInt(hour.slice(2));
62
51
  if (!isNaN(interval) && interval >= 1) {
63
52
  return true;
64
53
  }
54
+ return false;
55
+ }
56
+
57
+ // Allow hourly patterns: {0|30} * * * * (every hour at :00 or :30)
58
+ if ((minute === '0' || minute === '30') && hour === '*') {
59
+ return true;
65
60
  }
66
61
 
67
- // Allow specific hour patterns: 0 N * * * (runs once per day)
68
- if (minute === '0' && /^\d+$/.test(hour)) {
62
+ // Allow specific hour patterns: {0|30} N * * * (runs once per day)
63
+ // or {0|30} N * * {weekdays} (runs on specific weekdays)
64
+ if ((minute === '0' || minute === '30') && /^\d+$/.test(hour)) {
69
65
  const h = parseInt(hour);
70
66
  if (!isNaN(h) && h >= 0 && h <= 23) {
71
67
  return true;
@@ -182,4 +182,9 @@ export interface ConversationContext {
182
182
  * Topic ID
183
183
  */
184
184
  topicId?: string | null;
185
+ /**
186
+ * Topic share ID for public access (used by shared topic pages)
187
+ * When present, allows unauthenticated access to topic messages
188
+ */
189
+ topicShareId?: string;
185
190
  }
@@ -49,6 +49,7 @@ export type ServerLanguageModel = Partial<Record<GlobalLLMProviderKey, ServerMod
49
49
  export interface GlobalServerConfig {
50
50
  aiProvider: ServerLanguageModel;
51
51
  defaultAgent?: PartialDeep<UserDefaultAgent>;
52
+ enableBusinessFeatures?: boolean;
52
53
  enableEmailVerification?: boolean;
53
54
  enableKlavis?: boolean;
54
55
  enableLobehubSkill?: boolean;