@lobehub/lobehub 2.0.0-next.277 → 2.0.0-next.279
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.
- package/.cursor/rules/db-migrations.mdc +1 -1
- package/.cursor/rules/debug-usage.mdc +7 -5
- package/.cursor/rules/desktop-controller-tests.mdc +2 -1
- package/.cursor/rules/desktop-feature-implementation.mdc +9 -5
- package/.cursor/rules/desktop-local-tools-implement.mdc +67 -66
- package/.cursor/rules/desktop-menu-configuration.mdc +21 -9
- package/.cursor/rules/desktop-window-management.mdc +17 -2
- package/.cursor/rules/drizzle-schema-style-guide.mdc +6 -6
- package/.cursor/rules/hotkey.mdc +1 -0
- package/.cursor/rules/i18n.mdc +1 -0
- package/.cursor/rules/project-structure.mdc +16 -3
- package/.cursor/rules/react.mdc +17 -5
- package/.cursor/rules/recent-data-usage.mdc +2 -1
- package/.cursor/rules/testing-guide/testing-guide.mdc +262 -238
- package/.cursor/rules/testing-guide/zustand-store-action-test.mdc +1 -1
- package/.cursor/rules/zustand-action-patterns.mdc +1 -1
- package/.cursor/rules/zustand-slice-organization.mdc +4 -4
- package/CHANGELOG.md +51 -0
- package/CLAUDE.md +1 -1
- package/GEMINI.md +1 -1
- package/changelog/v1.json +14 -0
- package/docs/development/database-schema.dbml +16 -0
- package/locales/en-US/chat.json +24 -0
- package/locales/zh-CN/chat.json +24 -0
- package/package.json +1 -1
- package/packages/business/const/src/index.ts +3 -0
- package/packages/database/migrations/0069_add_topic_shares_table.sql +22 -0
- package/packages/database/migrations/meta/0069_snapshot.json +9704 -0
- package/packages/database/migrations/meta/_journal.json +7 -0
- package/packages/database/src/models/__tests__/topicShare.test.ts +318 -0
- package/packages/database/src/models/topicShare.ts +177 -0
- package/packages/database/src/schemas/topic.ts +44 -2
- package/packages/types/src/conversation.ts +5 -0
- package/packages/types/src/topic/topic.ts +46 -0
- package/src/app/[variants]/(main)/agent/features/Conversation/Header/ShareButton/index.tsx +24 -9
- package/src/app/[variants]/(main)/agent/features/Conversation/ThreadHydration.tsx +2 -1
- package/src/app/[variants]/(main)/agent/features/Portal/_layout/Mobile.tsx +3 -3
- package/src/app/[variants]/(main)/group/_layout/Sidebar/GroupConfig/GroupMember.tsx +3 -2
- package/src/app/[variants]/(main)/group/features/Conversation/Header/ShareButton/index.tsx +26 -9
- package/src/app/[variants]/(main)/group/features/Conversation/ThreadHydration.tsx +2 -1
- package/src/app/[variants]/(main)/group/features/Portal/_layout/Mobile.tsx +3 -3
- package/src/app/[variants]/(main)/group/profile/features/MemberProfile/AgentTool.tsx +4 -1
- package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/AspectRatioSelect/index.tsx +1 -2
- package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/ImageNum.tsx +54 -173
- package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/ResolutionSelect.tsx +22 -67
- package/src/app/[variants]/(mobile)/router/mobileRouter.config.tsx +18 -0
- package/src/app/[variants]/router/desktopRouter.config.tsx +18 -0
- package/src/app/[variants]/share/t/[id]/SharedMessageList.tsx +54 -0
- package/src/app/[variants]/share/t/[id]/_layout/index.tsx +170 -0
- package/src/app/[variants]/share/t/[id]/features/Portal/index.tsx +66 -0
- package/src/app/[variants]/share/t/[id]/index.tsx +112 -0
- package/src/app/robots.tsx +1 -1
- package/src/business/client/BusinessMobileRoutes.tsx +1 -1
- package/src/features/Conversation/ChatList/index.tsx +17 -6
- package/src/features/Conversation/Messages/AssistantGroup/Tool/Render/index.tsx +8 -4
- package/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx +15 -10
- package/src/features/Conversation/Messages/AssistantGroup/Tools.tsx +3 -1
- package/src/features/Conversation/Messages/AssistantGroup/components/ContentBlock.tsx +3 -2
- package/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx +2 -2
- package/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx +25 -26
- package/src/features/Conversation/Messages/Supervisor/components/Group.tsx +4 -2
- package/src/features/Conversation/Messages/Tool/Tool/index.tsx +16 -12
- package/src/features/Conversation/Messages/Tool/index.tsx +20 -11
- package/src/features/Conversation/Messages/index.tsx +1 -1
- package/src/features/Conversation/store/slices/data/action.test.ts +42 -0
- package/src/features/Conversation/store/slices/data/action.ts +4 -2
- package/src/features/Portal/GroupThread/Header/index.tsx +2 -2
- package/src/features/Portal/MessageDetail/Body/index.tsx +3 -3
- package/src/features/Portal/components/Header.tsx +3 -3
- package/src/features/ProfileEditor/AgentTool.tsx +50 -19
- package/src/features/SharePopover/index.tsx +215 -0
- package/src/features/SharePopover/style.ts +10 -0
- package/src/hooks/useNavigateToAgent.ts +3 -3
- package/src/libs/next/proxy/define-config.ts +4 -1
- package/src/locales/default/chat.ts +26 -0
- package/src/proxy.ts +1 -0
- package/src/server/routers/lambda/__tests__/message.test.ts +152 -0
- package/src/server/routers/lambda/__tests__/share.test.ts +227 -0
- package/src/server/routers/lambda/__tests__/topic.test.ts +174 -0
- package/src/server/routers/lambda/index.ts +2 -0
- package/src/server/routers/lambda/message.ts +37 -4
- package/src/server/routers/lambda/share.ts +55 -0
- package/src/server/routers/lambda/topic.ts +45 -0
- package/src/services/message/index.ts +1 -0
- package/src/services/topic/index.ts +16 -0
- package/src/store/chat/slices/portal/action.test.ts +0 -41
- package/src/store/chat/slices/portal/action.ts +0 -25
- package/src/store/chat/slices/thread/action.test.ts +10 -6
- package/src/store/chat/slices/thread/action.ts +10 -3
- package/src/app/[variants]/(main)/group/features/Portal/features/Portal.tsx +0 -105
- package/src/app/[variants]/(main)/group/features/Portal/features/PortalPanel.tsx +0 -23
|
@@ -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 {
|
|
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;
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { BaseDataModel } from '../meta';
|
|
2
2
|
|
|
3
3
|
// Type definitions
|
|
4
|
+
export type ShareVisibility = 'private' | 'link';
|
|
5
|
+
|
|
4
6
|
export type TimeGroupId =
|
|
5
7
|
| 'today'
|
|
6
8
|
| 'yesterday'
|
|
@@ -126,3 +128,47 @@ export interface QueryTopicParams {
|
|
|
126
128
|
isInbox?: boolean;
|
|
127
129
|
pageSize?: number;
|
|
128
130
|
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Shared message data for public sharing
|
|
134
|
+
*/
|
|
135
|
+
export interface SharedMessage {
|
|
136
|
+
content: string;
|
|
137
|
+
createdAt: Date;
|
|
138
|
+
id: string;
|
|
139
|
+
role: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Shared topic data returned by public API
|
|
144
|
+
*/
|
|
145
|
+
export interface SharedTopicData {
|
|
146
|
+
agentId: string | null;
|
|
147
|
+
agentMeta?: {
|
|
148
|
+
avatar?: string | null;
|
|
149
|
+
backgroundColor?: string | null;
|
|
150
|
+
marketIdentifier?: string | null;
|
|
151
|
+
slug?: string | null;
|
|
152
|
+
title?: string | null;
|
|
153
|
+
};
|
|
154
|
+
groupId: string | null;
|
|
155
|
+
groupMeta?: {
|
|
156
|
+
avatar?: string | null;
|
|
157
|
+
backgroundColor?: string | null;
|
|
158
|
+
members?: { avatar: string | null; backgroundColor: string | null }[];
|
|
159
|
+
title?: string | null;
|
|
160
|
+
};
|
|
161
|
+
shareId: string;
|
|
162
|
+
title: string | null;
|
|
163
|
+
topicId: string;
|
|
164
|
+
visibility: ShareVisibility;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Topic share info returned to the owner
|
|
169
|
+
*/
|
|
170
|
+
export interface TopicShareInfo {
|
|
171
|
+
id: string;
|
|
172
|
+
topicId: string;
|
|
173
|
+
visibility: ShareVisibility;
|
|
174
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { ENABLE_TOPIC_LINK_SHARE } from '@lobechat/business-const';
|
|
3
4
|
import { ActionIcon } from '@lobehub/ui';
|
|
4
5
|
import { Share2 } from 'lucide-react';
|
|
5
6
|
import dynamic from 'next/dynamic';
|
|
@@ -8,8 +9,10 @@ import { useTranslation } from 'react-i18next';
|
|
|
8
9
|
|
|
9
10
|
import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
|
10
11
|
import { useWorkspaceModal } from '@/hooks/useWorkspaceModal';
|
|
12
|
+
import { useChatStore } from '@/store/chat';
|
|
11
13
|
|
|
12
14
|
const ShareModal = dynamic(() => import('@/features/ShareModal'));
|
|
15
|
+
const SharePopover = dynamic(() => import('@/features/SharePopover'));
|
|
13
16
|
|
|
14
17
|
interface ShareButtonProps {
|
|
15
18
|
mobile?: boolean;
|
|
@@ -20,18 +23,30 @@ interface ShareButtonProps {
|
|
|
20
23
|
const ShareButton = memo<ShareButtonProps>(({ mobile, setOpen, open }) => {
|
|
21
24
|
const [isModalOpen, setIsModalOpen] = useWorkspaceModal(open, setOpen);
|
|
22
25
|
const { t } = useTranslation('common');
|
|
26
|
+
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
|
27
|
+
|
|
28
|
+
// Hide share button when no topic exists (no messages sent yet)
|
|
29
|
+
if (!activeTopicId) return null;
|
|
30
|
+
|
|
31
|
+
const iconButton = (
|
|
32
|
+
<ActionIcon
|
|
33
|
+
icon={Share2}
|
|
34
|
+
onClick={ENABLE_TOPIC_LINK_SHARE ? undefined : () => setIsModalOpen(true)}
|
|
35
|
+
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
|
|
36
|
+
title={t('share')}
|
|
37
|
+
tooltipProps={{
|
|
38
|
+
placement: 'bottom',
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
23
42
|
|
|
24
43
|
return (
|
|
25
44
|
<>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
tooltipProps={{
|
|
32
|
-
placement: 'bottom',
|
|
33
|
-
}}
|
|
34
|
-
/>
|
|
45
|
+
{ENABLE_TOPIC_LINK_SHARE ? (
|
|
46
|
+
<SharePopover onOpenModal={() => setIsModalOpen(true)}>{iconButton}</SharePopover>
|
|
47
|
+
) : (
|
|
48
|
+
iconButton
|
|
49
|
+
)}
|
|
35
50
|
<ShareModal onCancel={() => setIsModalOpen(false)} open={isModalOpen} />
|
|
36
51
|
</>
|
|
37
52
|
);
|