@lobehub/lobehub 2.0.0-next.277 → 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 (73) 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 +25 -0
  19. package/CLAUDE.md +1 -1
  20. package/GEMINI.md +1 -1
  21. package/changelog/v1.json +5 -0
  22. package/docs/development/database-schema.dbml +16 -0
  23. package/locales/en-US/chat.json +24 -0
  24. package/locales/zh-CN/chat.json +24 -0
  25. package/package.json +1 -1
  26. package/packages/business/const/src/index.ts +3 -0
  27. package/packages/database/migrations/0069_add_topic_shares_table.sql +22 -0
  28. package/packages/database/migrations/meta/0069_snapshot.json +9704 -0
  29. package/packages/database/migrations/meta/_journal.json +7 -0
  30. package/packages/database/src/models/__tests__/topicShare.test.ts +318 -0
  31. package/packages/database/src/models/topicShare.ts +177 -0
  32. package/packages/database/src/schemas/topic.ts +44 -2
  33. package/packages/types/src/conversation.ts +5 -0
  34. package/packages/types/src/topic/topic.ts +46 -0
  35. package/src/app/[variants]/(main)/agent/features/Conversation/Header/ShareButton/index.tsx +24 -9
  36. package/src/app/[variants]/(main)/group/features/Conversation/Header/ShareButton/index.tsx +26 -9
  37. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/AspectRatioSelect/index.tsx +1 -2
  38. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/ImageNum.tsx +54 -173
  39. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/ResolutionSelect.tsx +22 -67
  40. package/src/app/[variants]/(mobile)/router/mobileRouter.config.tsx +18 -0
  41. package/src/app/[variants]/router/desktopRouter.config.tsx +18 -0
  42. package/src/app/[variants]/share/t/[id]/SharedMessageList.tsx +54 -0
  43. package/src/app/[variants]/share/t/[id]/_layout/index.tsx +170 -0
  44. package/src/app/[variants]/share/t/[id]/features/Portal/index.tsx +66 -0
  45. package/src/app/[variants]/share/t/[id]/index.tsx +112 -0
  46. package/src/app/robots.tsx +1 -1
  47. package/src/business/client/BusinessMobileRoutes.tsx +1 -1
  48. package/src/features/Conversation/ChatList/index.tsx +12 -5
  49. package/src/features/Conversation/Messages/AssistantGroup/Tool/Render/index.tsx +8 -4
  50. package/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx +15 -10
  51. package/src/features/Conversation/Messages/AssistantGroup/Tools.tsx +3 -1
  52. package/src/features/Conversation/Messages/AssistantGroup/components/ContentBlock.tsx +3 -2
  53. package/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx +2 -2
  54. package/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx +25 -26
  55. package/src/features/Conversation/Messages/Supervisor/components/Group.tsx +4 -2
  56. package/src/features/Conversation/Messages/Tool/Tool/index.tsx +16 -12
  57. package/src/features/Conversation/Messages/Tool/index.tsx +20 -11
  58. package/src/features/Conversation/Messages/index.tsx +1 -1
  59. package/src/features/Conversation/store/slices/data/action.ts +2 -1
  60. package/src/features/SharePopover/index.tsx +215 -0
  61. package/src/features/SharePopover/style.ts +10 -0
  62. package/src/libs/next/proxy/define-config.ts +4 -1
  63. package/src/locales/default/chat.ts +26 -0
  64. package/src/proxy.ts +1 -0
  65. package/src/server/routers/lambda/__tests__/message.test.ts +152 -0
  66. package/src/server/routers/lambda/__tests__/share.test.ts +227 -0
  67. package/src/server/routers/lambda/__tests__/topic.test.ts +174 -0
  68. package/src/server/routers/lambda/index.ts +2 -0
  69. package/src/server/routers/lambda/message.ts +37 -4
  70. package/src/server/routers/lambda/share.ts +55 -0
  71. package/src/server/routers/lambda/topic.ts +45 -0
  72. package/src/services/message/index.ts +1 -0
  73. package/src/services/topic/index.ts +16 -0
@@ -20,6 +20,7 @@ const Render = dynamic(() => import('../../AssistantGroup/Tool/Render'), {
20
20
  export interface InspectorProps {
21
21
  apiName: string;
22
22
  arguments?: string;
23
+ disableEditing?: boolean;
23
24
  identifier: string;
24
25
  index: number;
25
26
  messageId: string;
@@ -32,7 +33,7 @@ export interface InspectorProps {
32
33
  * Tool message component - adapts Tool message data to use AssistantGroup/Tool components
33
34
  */
34
35
  const Tool = memo<InspectorProps>(
35
- ({ arguments: requestArgs, apiName, messageId, toolCallId, index, identifier, type }) => {
36
+ ({ arguments: requestArgs, apiName, disableEditing, messageId, toolCallId, index, identifier, type }) => {
36
37
  const [showDebug, setShowDebug] = useState(false);
37
38
  const [showPluginRender, setShowPluginRender] = useState(false);
38
39
  const [expand, setExpand] = useState(true);
@@ -66,16 +67,18 @@ const Tool = memo<InspectorProps>(
66
67
  >
67
68
  <AccordionItem
68
69
  action={
69
- <Actions
70
- assistantMessageId={messageId}
71
- handleExpand={(expand) => setExpand(!!expand)}
72
- identifier={identifier}
73
- setShowDebug={setShowDebug}
74
- setShowPluginRender={setShowPluginRender}
75
- showCustomPluginRender={false}
76
- showDebug={showDebug}
77
- showPluginRender={showPluginRender}
78
- />
70
+ !disableEditing && (
71
+ <Actions
72
+ assistantMessageId={messageId}
73
+ handleExpand={(expand) => setExpand(!!expand)}
74
+ identifier={identifier}
75
+ setShowDebug={setShowDebug}
76
+ setShowPluginRender={setShowPluginRender}
77
+ showCustomPluginRender={false}
78
+ showDebug={showDebug}
79
+ showPluginRender={showPluginRender}
80
+ />
81
+ )
79
82
  }
80
83
  itemKey={'tool'}
81
84
  paddingBlock={4}
@@ -83,7 +86,7 @@ const Tool = memo<InspectorProps>(
83
86
  title={<Inspectors apiName={apiName} identifier={identifier} result={result} />}
84
87
  >
85
88
  <Flexbox gap={8} paddingBlock={8}>
86
- {showDebug && (
89
+ {showDebug && !disableEditing && (
87
90
  <Debug
88
91
  apiName={apiName}
89
92
  identifier={identifier}
@@ -96,6 +99,7 @@ const Tool = memo<InspectorProps>(
96
99
  <Render
97
100
  apiName={apiName}
98
101
  arguments={requestArgs}
102
+ disableEditing={disableEditing}
99
103
  identifier={identifier}
100
104
  messageId={messageId}
101
105
  result={result}
@@ -8,11 +8,12 @@ import { dataSelectors, useConversationStore } from '../../store';
8
8
  import Tool from './Tool';
9
9
 
10
10
  interface ToolMessageProps {
11
+ disableEditing?: boolean;
11
12
  id: string;
12
13
  index: number;
13
14
  }
14
15
 
15
- const ToolMessage = memo<ToolMessageProps>(({ id, index }) => {
16
+ const ToolMessage = memo<ToolMessageProps>(({ disableEditing, id, index }) => {
16
17
  const { t } = useTranslation('plugin');
17
18
  const item = useConversationStore(dataSelectors.getDbMessageById(id), isEqual) as UIChatMessage;
18
19
  const deleteToolMessage = useConversationStore((s) => s.deleteToolMessage);
@@ -29,17 +30,25 @@ const ToolMessage = memo<ToolMessageProps>(({ id, index }) => {
29
30
 
30
31
  return (
31
32
  <Flexbox gap={4} paddingBlock={12}>
32
- <Alert
33
- action={
34
- <Button loading={loading} onClick={handleDelete} size={'small'} type={'primary'}>
35
- {t('inspector.delete')}
36
- </Button>
37
- }
38
- title={t('inspector.orphanedToolCall')}
39
- type={'secondary'}
40
- />
33
+ {!disableEditing && (
34
+ <Alert
35
+ action={
36
+ <Button loading={loading} onClick={handleDelete} size={'small'} type={'primary'}>
37
+ {t('inspector.delete')}
38
+ </Button>
39
+ }
40
+ title={t('inspector.orphanedToolCall')}
41
+ type={'secondary'}
42
+ />
43
+ )}
41
44
  {item.plugin && (
42
- <Tool {...item.plugin} index={index} messageId={id} toolCallId={item.tool_call_id!} />
45
+ <Tool
46
+ {...item.plugin}
47
+ disableEditing={disableEditing}
48
+ index={index}
49
+ messageId={id}
50
+ toolCallId={item.tool_call_id!}
51
+ />
43
52
  )}
44
53
  </Flexbox>
45
54
  );
@@ -158,7 +158,7 @@ const MessageItem = memo<MessageItemProps>(
158
158
  }
159
159
 
160
160
  case 'tool': {
161
- return <ToolMessage id={id} index={index} />;
161
+ return <ToolMessage disableEditing={disableEditing} id={id} index={index} />;
162
162
  }
163
163
  }
164
164
 
@@ -103,13 +103,14 @@ export const dataSlice: StateCreator<
103
103
  useFetchMessages: (context, skipFetch) => {
104
104
  // When skipFetch is true, SWR key is null - no fetch occurs
105
105
  // This is used when external messages are provided (e.g., creating new thread)
106
+ // Allow fetch if: has agentId (both agent topics and group topics have agentId)
106
107
  const shouldFetch = !skipFetch && !!context.agentId;
107
108
 
108
109
  return useClientDataSWRWithSync<UIChatMessage[]>(
109
110
  shouldFetch ? ['CONVERSATION_FETCH_MESSAGES', context] : null,
110
111
 
111
112
  async () => {
112
- return messageService.getMessages(context as any);
113
+ return messageService.getMessages(context);
113
114
  },
114
115
  {
115
116
  onData: (data) => {
@@ -0,0 +1,215 @@
1
+ 'use client';
2
+
3
+ import { Button, Flexbox, Popover, copyToClipboard, usePopoverContext } from '@lobehub/ui';
4
+ import { App, Divider, Select, Skeleton, Typography } from 'antd';
5
+ import { CopyIcon, ExternalLinkIcon, LinkIcon, LockIcon } from 'lucide-react';
6
+ import { type ReactNode, memo, useCallback, useEffect, useRef, useState } from 'react';
7
+ import { useTranslation } from 'react-i18next';
8
+ import useSWR from 'swr';
9
+
10
+ import { useIsMobile } from '@/hooks/useIsMobile';
11
+ import { topicService } from '@/services/topic';
12
+ import { useChatStore } from '@/store/chat';
13
+
14
+ import { useStyles } from './style';
15
+
16
+ type Visibility = 'private' | 'link';
17
+
18
+ interface SharePopoverContentProps {
19
+ onOpenModal?: () => void;
20
+ }
21
+
22
+ const SharePopoverContent = memo<SharePopoverContentProps>(({ onOpenModal }) => {
23
+ const { t } = useTranslation('chat');
24
+ const { message, modal } = App.useApp();
25
+ const { styles } = useStyles();
26
+ const [updating, setUpdating] = useState(false);
27
+ const { close } = usePopoverContext();
28
+ const containerRef = useRef<HTMLDivElement>(null);
29
+
30
+ const activeTopicId = useChatStore((s) => s.activeTopicId);
31
+
32
+ const {
33
+ data: shareInfo,
34
+ isLoading,
35
+ mutate,
36
+ } = useSWR(
37
+ activeTopicId ? ['topic-share-info', activeTopicId] : null,
38
+ () => topicService.getShareInfo(activeTopicId!),
39
+ { revalidateOnFocus: false },
40
+ );
41
+
42
+ // Auto-create share record if not exists
43
+ useEffect(() => {
44
+ if (!isLoading && !shareInfo && activeTopicId) {
45
+ topicService.enableSharing(activeTopicId, 'private').then(() => mutate());
46
+ }
47
+ }, [isLoading, shareInfo, activeTopicId, mutate]);
48
+
49
+ const shareUrl = shareInfo?.id ? `${window.location.origin}/share/t/${shareInfo.id}` : '';
50
+ const currentVisibility = (shareInfo?.visibility as Visibility) || 'private';
51
+
52
+ const updateVisibility = useCallback(
53
+ async (visibility: Visibility) => {
54
+ if (!activeTopicId) return;
55
+
56
+ setUpdating(true);
57
+ try {
58
+ await topicService.updateShareVisibility(activeTopicId, visibility);
59
+ await mutate();
60
+ message.success(t('shareModal.link.visibilityUpdated'));
61
+ } catch {
62
+ message.error(t('shareModal.link.updateError'));
63
+ } finally {
64
+ setUpdating(false);
65
+ }
66
+ },
67
+ [activeTopicId, mutate, message, t],
68
+ );
69
+
70
+ const handleVisibilityChange = useCallback(
71
+ (visibility: Visibility) => {
72
+ // Show confirmation when changing from private to link
73
+ if (currentVisibility === 'private' && visibility === 'link') {
74
+ modal.confirm({
75
+ cancelText: t('cancel', { ns: 'common' }),
76
+ content: t('shareModal.popover.privacyWarning.content'),
77
+ okText: t('shareModal.popover.privacyWarning.confirm'),
78
+ onOk: () => updateVisibility(visibility),
79
+ title: t('shareModal.popover.privacyWarning.title'),
80
+ type: 'warning',
81
+ });
82
+ } else {
83
+ updateVisibility(visibility);
84
+ }
85
+ },
86
+ [currentVisibility, modal, t, updateVisibility],
87
+ );
88
+
89
+ const handleCopyLink = useCallback(async () => {
90
+ if (!shareUrl) return;
91
+ await copyToClipboard(shareUrl);
92
+ message.success(t('shareModal.copyLinkSuccess'));
93
+ }, [shareUrl, message, t]);
94
+
95
+ const handleOpenModal = useCallback(() => {
96
+ close();
97
+ onOpenModal?.();
98
+ }, [close, onOpenModal]);
99
+
100
+ // Loading state
101
+ if (isLoading || !shareInfo) {
102
+ return (
103
+ <Flexbox className={styles.container} gap={16}>
104
+ <Typography.Text strong>{t('share', { ns: 'common' })}</Typography.Text>
105
+ <Skeleton active paragraph={{ rows: 2 }} />
106
+ </Flexbox>
107
+ );
108
+ }
109
+
110
+ const visibilityOptions = [
111
+ {
112
+ icon: <LockIcon size={14} />,
113
+ label: t('shareModal.link.permissionPrivate'),
114
+ value: 'private',
115
+ },
116
+ {
117
+ icon: <LinkIcon size={14} />,
118
+ label: t('shareModal.link.permissionLink'),
119
+ value: 'link',
120
+ },
121
+ ];
122
+
123
+ const getVisibilityHint = () => {
124
+ switch (currentVisibility) {
125
+ case 'private': {
126
+ return t('shareModal.link.privateHint');
127
+ }
128
+ case 'link': {
129
+ return t('shareModal.link.linkHint');
130
+ }
131
+ }
132
+ };
133
+
134
+ return (
135
+ <Flexbox className={styles.container} gap={12} ref={containerRef}>
136
+ <Typography.Text strong>{t('shareModal.popover.title')}</Typography.Text>
137
+
138
+ <Flexbox gap={4}>
139
+ <Typography.Text type="secondary">{t('shareModal.popover.visibility')}</Typography.Text>
140
+ <Select
141
+ disabled={updating}
142
+ getPopupContainer={() => containerRef.current || document.body}
143
+ labelRender={({ value }) => {
144
+ const option = visibilityOptions.find((o) => o.value === value);
145
+ return (
146
+ <Flexbox align="center" gap={8} horizontal>
147
+ {option?.icon}
148
+ {option?.label}
149
+ </Flexbox>
150
+ );
151
+ }}
152
+ onChange={handleVisibilityChange}
153
+ optionRender={(option) => (
154
+ <Flexbox align="center" gap={8} horizontal>
155
+ {visibilityOptions.find((o) => o.value === option.value)?.icon}
156
+ {option.label}
157
+ </Flexbox>
158
+ )}
159
+ options={visibilityOptions}
160
+ style={{ width: '100%' }}
161
+ value={currentVisibility}
162
+ />
163
+ </Flexbox>
164
+
165
+ <Typography.Text className={styles.hint} type="secondary">
166
+ {getVisibilityHint()}
167
+ </Typography.Text>
168
+
169
+ <Divider style={{ margin: '4px 0' }} />
170
+
171
+ <Flexbox align="center" horizontal justify="space-between">
172
+ <Button
173
+ icon={ExternalLinkIcon}
174
+ onClick={handleOpenModal}
175
+ size="small"
176
+ type="text"
177
+ variant="text"
178
+ >
179
+ {t('shareModal.popover.moreOptions')}
180
+ </Button>
181
+ <Button icon={CopyIcon} onClick={handleCopyLink} size="small" type="primary">
182
+ {t('shareModal.copyLink')}
183
+ </Button>
184
+ </Flexbox>
185
+ </Flexbox>
186
+ );
187
+ });
188
+
189
+ interface SharePopoverProps {
190
+ children?: ReactNode;
191
+ onOpenModal?: () => void;
192
+ }
193
+
194
+ const SharePopover = memo<SharePopoverProps>(({ children, onOpenModal }) => {
195
+ const isMobile = useIsMobile();
196
+
197
+ return (
198
+ <Popover
199
+ arrow={false}
200
+ content={<SharePopoverContent onOpenModal={onOpenModal} />}
201
+ placement={isMobile ? 'top' : 'bottomRight'}
202
+ styles={{
203
+ content: {
204
+ padding: 0,
205
+ width: isMobile ? '100vw' : 366,
206
+ },
207
+ }}
208
+ trigger={['click']}
209
+ >
210
+ {children}
211
+ </Popover>
212
+ );
213
+ });
214
+
215
+ export default SharePopover;
@@ -0,0 +1,10 @@
1
+ import { createStyles } from 'antd-style';
2
+
3
+ export const useStyles = createStyles(({ css }) => ({
4
+ container: css`
5
+ padding: 16px;
6
+ `,
7
+ hint: css`
8
+ font-size: 12px;
9
+ `,
10
+ }));
@@ -8,7 +8,7 @@ import { auth } from '@/auth';
8
8
  import { LOBE_LOCALE_COOKIE } from '@/const/locale';
9
9
  import { isDesktop } from '@/const/version';
10
10
  import { appEnv } from '@/envs/app';
11
- import { OAUTH_AUTHORIZED , authEnv } from '@/envs/auth';
11
+ import { OAUTH_AUTHORIZED, authEnv } from '@/envs/auth';
12
12
  import NextAuth from '@/libs/next-auth';
13
13
  import { type Locales } from '@/locales/resources';
14
14
  import { parseBrowserLanguage } from '@/utils/locale';
@@ -107,6 +107,7 @@ export function defineConfig() {
107
107
  '/me',
108
108
  '/desktop-onboarding',
109
109
  '/onboarding',
110
+ '/share',
110
111
  ];
111
112
  const isSpaRoute = spaRoutes.some((route) => url.pathname.startsWith(route));
112
113
 
@@ -184,6 +185,8 @@ export function defineConfig() {
184
185
  '/oidc/token',
185
186
  // market
186
187
  '/market-auth-callback',
188
+ // public share pages
189
+ '/share(.*)',
187
190
  ]);
188
191
 
189
192
  const isProtectedRoute = createRouteMatcher([
@@ -283,6 +283,8 @@ export default {
283
283
  'sessionGroup.sorting': 'Group sorting updating...',
284
284
  'sessionGroup.tooLong': 'Group name length should be between 1-20',
285
285
  'shareModal.copy': 'Copy',
286
+ 'shareModal.copyLink': 'Copy Link',
287
+ 'shareModal.copyLinkSuccess': 'Link copied',
286
288
  'shareModal.download': 'Download Screenshot',
287
289
  'shareModal.downloadError': 'Download failed',
288
290
  'shareModal.downloadFile': 'Download File',
@@ -298,12 +300,27 @@ export default {
298
300
  'shareModal.imageType': 'Image Format',
299
301
  'shareModal.includeTool': 'Include Skill messages',
300
302
  'shareModal.includeUser': 'Include User Messages',
303
+ 'shareModal.link': 'Link',
304
+ 'shareModal.link.linkHint': 'Anyone with the link can view this topic',
305
+ 'shareModal.link.noTopic': 'Start a conversation first to share',
306
+ 'shareModal.link.permissionLink': 'Anyone with the link',
307
+ 'shareModal.link.permissionPrivate': 'Private',
308
+ 'shareModal.link.privateHint': 'Only you can access this link',
309
+ 'shareModal.link.updateError': 'Failed to update sharing settings',
310
+ 'shareModal.link.visibilityUpdated': 'Visibility updated',
301
311
  'shareModal.loadingPdf': 'Loading PDF...',
302
312
  'shareModal.noPdfData': 'No PDF data available',
303
313
  'shareModal.pdf': 'PDF',
304
314
  'shareModal.pdfErrorDescription': 'An error occurred while generating the PDF, please try again',
305
315
  'shareModal.pdfGenerationError': 'PDF generation failed',
306
316
  'shareModal.pdfReady': 'PDF is ready',
317
+ 'shareModal.popover.moreOptions': 'More share options',
318
+ 'shareModal.popover.privacyWarning.confirm': 'I understand, continue',
319
+ 'shareModal.popover.privacyWarning.content':
320
+ 'Please ensure the conversation does not contain any private or sensitive information before sharing. LobeHub is not responsible for any security issues that may arise from sharing.',
321
+ 'shareModal.popover.privacyWarning.title': 'Privacy Notice',
322
+ 'shareModal.popover.title': 'Share Topic',
323
+ 'shareModal.popover.visibility': 'Visibility',
307
324
  'shareModal.regeneratePdf': 'Regenerate PDF',
308
325
  'shareModal.screenshot': 'Screenshot',
309
326
  'shareModal.settings': 'Export Settings',
@@ -316,6 +333,15 @@ export default {
316
333
  'shareModal.withPluginInfo': 'Include Skill Information',
317
334
  'shareModal.withRole': 'Include Message Role',
318
335
  'shareModal.withSystemRole': 'Include Agent Profile',
336
+ 'sharePage.error.forbidden.subtitle': 'This share is private and not accessible.',
337
+ 'sharePage.error.forbidden.title': 'Access Denied',
338
+ 'sharePage.error.notFound.subtitle': 'This topic does not exist or has been removed.',
339
+ 'sharePage.error.notFound.title': 'Topic Not Found',
340
+ 'sharePage.error.unauthorized.action': 'Sign In',
341
+ 'sharePage.error.unauthorized.subtitle': 'Please sign in to view this shared topic.',
342
+ 'sharePage.error.unauthorized.title': 'Sign In Required',
343
+ 'sharePageDisclaimer':
344
+ 'This content is shared by a user and does not represent the views of LobeHub. LobeHub is not responsible for any consequences arising from this shared content.',
319
345
  'stt.action': 'Voice Input',
320
346
  'stt.loading': 'Recognizing...',
321
347
  'stt.prettifying': 'Polishing...',
package/src/proxy.ts CHANGED
@@ -26,6 +26,7 @@ export const config = {
26
26
  '/page(.*)',
27
27
  '/me',
28
28
  '/me(.*)',
29
+ '/share(.*)',
29
30
  '/desktop-onboarding',
30
31
  '/desktop-onboarding(.*)',
31
32
  '/onboarding',
@@ -1,13 +1,21 @@
1
1
  import { CreateMessageParams, UIChatMessage, UpdateMessageRAGParams } from '@lobechat/types';
2
+ import { TRPCError } from '@trpc/server';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
 
4
5
  import { MessageModel } from '@/database/models/message';
6
+ import { TopicShareModel } from '@/database/models/topicShare';
5
7
  import { FileService } from '@/server/services/file';
6
8
 
7
9
  vi.mock('@/database/models/message', () => ({
8
10
  MessageModel: vi.fn(),
9
11
  }));
10
12
 
13
+ vi.mock('@/database/models/topicShare', () => ({
14
+ TopicShareModel: {
15
+ findByShareIdWithAccessCheck: vi.fn(),
16
+ },
17
+ }));
18
+
11
19
  vi.mock('@/server/services/file', () => ({
12
20
  FileService: vi.fn(),
13
21
  }));
@@ -345,4 +353,148 @@ describe('messageRouter', () => {
345
353
  expect(result.rowCount).toBe(5);
346
354
  });
347
355
  });
356
+
357
+ describe('topicShareId support', () => {
358
+ it('should get messages via topicShareId for link share', async () => {
359
+ const mockShare = {
360
+ visibility: 'link',
361
+ ownerId: 'owner-user',
362
+ shareId: 'share-123',
363
+ topicId: 'topic-1',
364
+ };
365
+
366
+ const mockMessages = [
367
+ { id: 'msg1', content: 'Hello', role: 'user' },
368
+ { id: 'msg2', content: 'Hi there', role: 'assistant' },
369
+ ];
370
+
371
+ const mockQuery = vi.fn().mockResolvedValue(mockMessages);
372
+ const mockGetFullFileUrl = vi
373
+ .fn()
374
+ .mockImplementation((path: string) => `https://cdn/${path}`);
375
+
376
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
377
+ vi.mocked(MessageModel).mockImplementation(
378
+ () =>
379
+ ({
380
+ query: mockQuery,
381
+ }) as any,
382
+ );
383
+ vi.mocked(FileService).mockImplementation(
384
+ () =>
385
+ ({
386
+ getFullFileUrl: mockGetFullFileUrl,
387
+ }) as any,
388
+ );
389
+
390
+ // Simulate the router logic
391
+ const share = await TopicShareModel.findByShareIdWithAccessCheck(
392
+ {} as any,
393
+ 'share-123',
394
+ undefined,
395
+ );
396
+
397
+ expect(share).toBeDefined();
398
+ expect(share.topicId).toBe('topic-1');
399
+ expect(share.ownerId).toBe('owner-user');
400
+
401
+ // Create model using owner's id
402
+ const messageModel = new MessageModel({} as any, share.ownerId);
403
+ const result = await messageModel.query(
404
+ { topicId: share.topicId },
405
+ { postProcessUrl: mockGetFullFileUrl },
406
+ );
407
+
408
+ expect(result).toEqual(mockMessages);
409
+ });
410
+
411
+ it('should allow owner to access private share messages', async () => {
412
+ const mockShare = {
413
+ visibility: 'private',
414
+ ownerId: 'owner-user',
415
+ shareId: 'private-share',
416
+ topicId: 'topic-private',
417
+ };
418
+
419
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
420
+
421
+ const share = await TopicShareModel.findByShareIdWithAccessCheck(
422
+ {} as any,
423
+ 'private-share',
424
+ 'owner-user', // Owner accessing
425
+ );
426
+
427
+ expect(share).toBeDefined();
428
+ expect(share.visibility).toBe('private');
429
+ });
430
+
431
+ it('should throw FORBIDDEN for private share accessed by non-owner', async () => {
432
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockRejectedValue(
433
+ new TRPCError({ code: 'FORBIDDEN', message: 'This share is private' }),
434
+ );
435
+
436
+ await expect(
437
+ TopicShareModel.findByShareIdWithAccessCheck({} as any, 'private-share', 'other-user'),
438
+ ).rejects.toThrow(TRPCError);
439
+
440
+ try {
441
+ await TopicShareModel.findByShareIdWithAccessCheck(
442
+ {} as any,
443
+ 'private-share',
444
+ 'other-user',
445
+ );
446
+ } catch (error) {
447
+ expect((error as TRPCError).code).toBe('FORBIDDEN');
448
+ }
449
+ });
450
+
451
+ it('should throw NOT_FOUND for non-existent share', async () => {
452
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockRejectedValue(
453
+ new TRPCError({ code: 'NOT_FOUND', message: 'Share not found' }),
454
+ );
455
+
456
+ await expect(
457
+ TopicShareModel.findByShareIdWithAccessCheck({} as any, 'non-existent', 'user1'),
458
+ ).rejects.toThrow(TRPCError);
459
+
460
+ try {
461
+ await TopicShareModel.findByShareIdWithAccessCheck({} as any, 'non-existent', 'user1');
462
+ } catch (error) {
463
+ expect((error as TRPCError).code).toBe('NOT_FOUND');
464
+ }
465
+ });
466
+
467
+ it('should use owner id to query messages for shared topic', async () => {
468
+ const mockShare = {
469
+ visibility: 'link',
470
+ ownerId: 'topic-owner',
471
+ shareId: 'share-abc',
472
+ topicId: 'shared-topic',
473
+ };
474
+
475
+ const mockQuery = vi.fn().mockResolvedValue([{ id: 'msg1' }]);
476
+
477
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
478
+ vi.mocked(MessageModel).mockImplementation(
479
+ () =>
480
+ ({
481
+ query: mockQuery,
482
+ }) as any,
483
+ );
484
+
485
+ const share = await TopicShareModel.findByShareIdWithAccessCheck(
486
+ {} as any,
487
+ 'share-abc',
488
+ undefined,
489
+ );
490
+
491
+ // Verify we use the owner's id to create MessageModel
492
+ const messageModel = new MessageModel({} as any, share.ownerId);
493
+ await messageModel.query({ topicId: share.topicId }, {});
494
+
495
+ // Verify MessageModel was instantiated with owner's id
496
+ expect(MessageModel).toHaveBeenCalledWith({} as any, 'topic-owner');
497
+ expect(mockQuery).toHaveBeenCalledWith({ topicId: 'shared-topic' }, {});
498
+ });
499
+ });
348
500
  });