@lobehub/chat 1.140.0 → 1.141.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/changelog/v1.json +24 -0
  3. package/locales/ar/chat.json +13 -0
  4. package/locales/ar/common.json +1 -0
  5. package/locales/ar/components.json +4 -0
  6. package/locales/ar/file.json +2 -2
  7. package/locales/bg-BG/chat.json +13 -0
  8. package/locales/bg-BG/common.json +1 -0
  9. package/locales/bg-BG/components.json +4 -0
  10. package/locales/bg-BG/file.json +2 -2
  11. package/locales/de-DE/chat.json +13 -0
  12. package/locales/de-DE/common.json +1 -0
  13. package/locales/de-DE/components.json +4 -0
  14. package/locales/de-DE/file.json +2 -2
  15. package/locales/en-US/chat.json +13 -0
  16. package/locales/en-US/common.json +1 -0
  17. package/locales/en-US/components.json +4 -0
  18. package/locales/en-US/file.json +2 -2
  19. package/locales/es-ES/chat.json +13 -0
  20. package/locales/es-ES/common.json +1 -0
  21. package/locales/es-ES/components.json +4 -0
  22. package/locales/es-ES/file.json +2 -2
  23. package/locales/fa-IR/chat.json +13 -0
  24. package/locales/fa-IR/common.json +1 -0
  25. package/locales/fa-IR/components.json +4 -0
  26. package/locales/fa-IR/file.json +2 -2
  27. package/locales/fr-FR/chat.json +13 -0
  28. package/locales/fr-FR/common.json +1 -0
  29. package/locales/fr-FR/components.json +4 -0
  30. package/locales/fr-FR/file.json +2 -2
  31. package/locales/it-IT/chat.json +13 -0
  32. package/locales/it-IT/common.json +1 -0
  33. package/locales/it-IT/components.json +4 -0
  34. package/locales/it-IT/file.json +2 -2
  35. package/locales/ja-JP/chat.json +13 -0
  36. package/locales/ja-JP/common.json +1 -0
  37. package/locales/ja-JP/components.json +4 -0
  38. package/locales/ja-JP/file.json +2 -2
  39. package/locales/ko-KR/chat.json +13 -0
  40. package/locales/ko-KR/common.json +1 -0
  41. package/locales/ko-KR/components.json +4 -0
  42. package/locales/ko-KR/file.json +2 -2
  43. package/locales/nl-NL/chat.json +13 -0
  44. package/locales/nl-NL/common.json +1 -0
  45. package/locales/nl-NL/components.json +4 -0
  46. package/locales/nl-NL/file.json +2 -2
  47. package/locales/pl-PL/chat.json +13 -0
  48. package/locales/pl-PL/common.json +1 -0
  49. package/locales/pl-PL/components.json +4 -0
  50. package/locales/pl-PL/file.json +2 -2
  51. package/locales/pt-BR/chat.json +13 -0
  52. package/locales/pt-BR/common.json +1 -0
  53. package/locales/pt-BR/components.json +4 -0
  54. package/locales/pt-BR/file.json +2 -2
  55. package/locales/ru-RU/chat.json +13 -0
  56. package/locales/ru-RU/common.json +1 -0
  57. package/locales/ru-RU/components.json +4 -0
  58. package/locales/ru-RU/file.json +2 -2
  59. package/locales/tr-TR/chat.json +13 -0
  60. package/locales/tr-TR/common.json +1 -0
  61. package/locales/tr-TR/components.json +4 -0
  62. package/locales/tr-TR/file.json +2 -2
  63. package/locales/vi-VN/chat.json +13 -0
  64. package/locales/vi-VN/common.json +1 -0
  65. package/locales/vi-VN/components.json +4 -0
  66. package/locales/vi-VN/file.json +2 -2
  67. package/locales/zh-CN/chat.json +13 -0
  68. package/locales/zh-CN/common.json +1 -0
  69. package/locales/zh-CN/components.json +4 -0
  70. package/locales/zh-CN/file.json +2 -2
  71. package/locales/zh-TW/chat.json +13 -0
  72. package/locales/zh-TW/common.json +1 -0
  73. package/locales/zh-TW/components.json +4 -0
  74. package/locales/zh-TW/file.json +2 -2
  75. package/next.config.ts +5 -6
  76. package/package.json +8 -2
  77. package/packages/context-engine/src/__tests__/pipeline.test.ts +7 -27
  78. package/packages/context-engine/src/pipeline.ts +5 -21
  79. package/packages/context-engine/src/types.ts +2 -2
  80. package/packages/database/src/models/__tests__/message.test.ts +200 -2
  81. package/packages/database/src/models/message.ts +13 -0
  82. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.test.ts +313 -0
  83. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +21 -5
  84. package/packages/model-runtime/src/providers/azureai/index.test.ts +12 -2
  85. package/packages/model-runtime/src/providers/groq/index.test.ts +449 -0
  86. package/packages/model-runtime/src/providers/groq/index.ts +46 -0
  87. package/src/app/[variants]/(main)/_layout/Desktop/SideBar/TopActions.tsx +3 -2
  88. package/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/ChatHeader/Tags/index.tsx +1 -1
  89. package/src/app/[variants]/(main)/files/(content)/@menu/features/KnowledgeBase/Item/index.tsx +10 -2
  90. package/src/features/ChatInput/InputEditor/index.tsx +2 -0
  91. package/src/features/Conversation/Messages/User/index.tsx +7 -17
  92. package/src/features/Conversation/components/ChatItem/ShareMessageModal/SharePdf/PdfPreview.tsx +361 -0
  93. package/src/features/Conversation/components/ChatItem/ShareMessageModal/SharePdf/index.tsx +119 -0
  94. package/src/features/Conversation/components/ChatItem/ShareMessageModal/SharePdf/style.ts +63 -0
  95. package/src/features/Conversation/components/ChatItem/ShareMessageModal/SharePdf/template.ts +24 -0
  96. package/src/features/Conversation/components/ChatItem/ShareMessageModal/SharePdf/usePdfGeneration.ts +93 -0
  97. package/src/features/Conversation/components/ShareMessageModal/ShareImage/Preview.tsx +1 -1
  98. package/src/features/Conversation/components/ShareMessageModal/index.tsx +39 -14
  99. package/src/features/FileManager/FileList/MasonryFileItem/MasonryItemWrapper.tsx +44 -0
  100. package/src/features/FileManager/FileList/MasonryFileItem/index.tsx +553 -0
  101. package/src/features/FileManager/FileList/MasonrySkeleton.tsx +57 -0
  102. package/src/features/FileManager/FileList/ToolBar/ViewSwitcher.tsx +45 -0
  103. package/src/features/FileManager/FileList/ToolBar/index.tsx +9 -1
  104. package/src/features/FileManager/FileList/index.tsx +83 -13
  105. package/src/features/FileManager/Header/FilesSearchBar.tsx +7 -2
  106. package/src/features/ShareModal/ShareImage/Preview.tsx +1 -1
  107. package/src/features/ShareModal/SharePdf/PdfPreview.tsx +361 -0
  108. package/src/features/ShareModal/SharePdf/index.tsx +194 -0
  109. package/src/features/ShareModal/SharePdf/usePdfGeneration.ts +90 -0
  110. package/src/features/ShareModal/index.tsx +40 -14
  111. package/src/features/ShareModal/style.ts +8 -5
  112. package/src/helpers/toolEngineering/index.ts +7 -1
  113. package/src/helpers/toolFilters.ts +35 -0
  114. package/src/libs/trpc/client/lambda.ts +7 -1
  115. package/src/locales/default/chat.ts +13 -0
  116. package/src/locales/default/common.ts +1 -0
  117. package/src/locales/default/components.ts +4 -0
  118. package/src/locales/default/file.ts +2 -2
  119. package/src/server/globalConfig/parseSystemAgent.ts +4 -2
  120. package/src/server/routers/lambda/exporter.ts +173 -3
  121. package/src/server/routers/lambda/message.ts +11 -0
  122. package/src/services/chat/contextEngineering.ts +1 -9
  123. package/src/store/agent/slices/chat/selectors/agent.ts +16 -6
  124. package/src/store/global/initialState.ts +2 -0
  125. package/src/store/tool/slices/builtin/selectors.ts +15 -5
@@ -0,0 +1,57 @@
1
+ import { Skeleton } from 'antd';
2
+ import { createStyles } from 'antd-style';
3
+ import { memo } from 'react';
4
+
5
+ const useStyles = createStyles(({ css, token }) => ({
6
+ card: css`
7
+ padding: 12px;
8
+ border: 1px solid ${token.colorBorderSecondary};
9
+ border-radius: ${token.borderRadiusLG}px;
10
+ background: ${token.colorBgContainer};
11
+ `,
12
+ grid: css`
13
+ display: grid;
14
+ gap: 16px;
15
+ padding-block: 12px;
16
+ padding-inline: 24px;
17
+ `,
18
+ }));
19
+
20
+ interface MasonrySkeletonProps {
21
+ columnCount: number;
22
+ }
23
+
24
+ const MasonrySkeleton = memo<MasonrySkeletonProps>(({ columnCount }) => {
25
+ const { styles } = useStyles();
26
+ // Generate varying heights for more natural masonry look
27
+ const heights = [180, 220, 200, 190, 240, 210, 200, 230, 180, 220, 210, 190];
28
+
29
+ return (
30
+ <div
31
+ className={styles.grid}
32
+ style={{
33
+ gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
34
+ }}
35
+ >
36
+ {Array.from({ length: 12 }).map((_, index) => (
37
+ <div className={styles.card} key={index}>
38
+ <Skeleton
39
+ active
40
+ paragraph={{
41
+ rows: 3,
42
+ width: ['100%', '80%', '60%'],
43
+ }}
44
+ style={{
45
+ height: heights[index],
46
+ }}
47
+ title={false}
48
+ />
49
+ </div>
50
+ ))}
51
+ </div>
52
+ );
53
+ });
54
+
55
+ MasonrySkeleton.displayName = 'MasonrySkeleton';
56
+
57
+ export default MasonrySkeleton;
@@ -0,0 +1,45 @@
1
+ import { ActionIcon } from '@lobehub/ui';
2
+ import { createStyles } from 'antd-style';
3
+ import { Grid3x3Icon, ListIcon } from 'lucide-react';
4
+ import { memo } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { Flexbox } from 'react-layout-kit';
7
+
8
+ export type ViewMode = 'list' | 'masonry';
9
+
10
+ interface ViewSwitcherProps {
11
+ onViewChange: (view: ViewMode) => void;
12
+ view: ViewMode;
13
+ }
14
+
15
+ const useStyles = createStyles(({ css }) => ({
16
+ container: css`
17
+ gap: 4px;
18
+ `,
19
+ }));
20
+
21
+ const ViewSwitcher = memo<ViewSwitcherProps>(({ onViewChange, view }) => {
22
+ const { t } = useTranslation('components');
23
+ const { styles } = useStyles();
24
+
25
+ return (
26
+ <Flexbox className={styles.container} horizontal>
27
+ <ActionIcon
28
+ active={view === 'list'}
29
+ icon={ListIcon}
30
+ onClick={() => onViewChange('list')}
31
+ size={16}
32
+ title={t('FileManager.view.list')}
33
+ />
34
+ <ActionIcon
35
+ active={view === 'masonry'}
36
+ icon={Grid3x3Icon}
37
+ onClick={() => onViewChange('masonry')}
38
+ size={16}
39
+ title={t('FileManager.view.masonry')}
40
+ />
41
+ </Flexbox>
42
+ );
43
+ });
44
+
45
+ export default ViewSwitcher;
@@ -10,6 +10,7 @@ import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported';
10
10
 
11
11
  import Config from './Config';
12
12
  import MultiSelectActions, { MultiSelectActionType } from './MultiSelectActions';
13
+ import ViewSwitcher, { ViewMode } from './ViewSwitcher';
13
14
 
14
15
  const useStyles = createStyles(({ css, token, isDarkMode }) => ({
15
16
  container: css`
@@ -23,12 +24,14 @@ interface MultiSelectActionsProps {
23
24
  config: { showFilesInKnowledgeBase: boolean };
24
25
  knowledgeBaseId?: string;
25
26
  onConfigChange: (config: { showFilesInKnowledgeBase: boolean }) => void;
27
+ onViewChange: (view: ViewMode) => void;
26
28
  selectCount: number;
27
29
  selectFileIds: string[];
28
30
  setSelectedFileIds: (ids: string[]) => void;
29
31
  showConfig?: boolean;
30
32
  total?: number;
31
33
  totalFileIds: string[];
34
+ viewMode: ViewMode;
32
35
  }
33
36
 
34
37
  const ToolBar = memo<MultiSelectActionsProps>(
@@ -42,6 +45,8 @@ const ToolBar = memo<MultiSelectActionsProps>(
42
45
  config,
43
46
  onConfigChange,
44
47
  knowledgeBaseId,
48
+ viewMode,
49
+ onViewChange,
45
50
  }) => {
46
51
  const { styles } = useStyles();
47
52
 
@@ -111,7 +116,10 @@ const ToolBar = memo<MultiSelectActionsProps>(
111
116
  selectCount={selectCount}
112
117
  total={total}
113
118
  />
114
- {showConfig && <Config config={config} onConfigChange={onConfigChange} />}
119
+ <Flexbox gap={8} horizontal>
120
+ <ViewSwitcher onViewChange={onViewChange} view={viewMode} />
121
+ {showConfig && <Config config={config} onConfigChange={onConfigChange} />}
122
+ </Flexbox>
115
123
  </Flexbox>
116
124
  );
117
125
  },
@@ -1,21 +1,26 @@
1
1
  'use client';
2
2
 
3
3
  import { Text } from '@lobehub/ui';
4
+ import { VirtuosoMasonry } from '@virtuoso.dev/masonry';
4
5
  import { createStyles } from 'antd-style';
5
6
  import { useQueryState } from 'nuqs';
6
7
  import { rgba } from 'polished';
7
- import { memo, useState } from 'react';
8
+ import React, { memo, useState } from 'react';
8
9
  import { useTranslation } from 'react-i18next';
9
10
  import { Center, Flexbox } from 'react-layout-kit';
10
11
  import { Virtuoso } from 'react-virtuoso';
11
12
 
12
13
  import { useFileStore } from '@/store/file';
14
+ import { useGlobalStore } from '@/store/global';
13
15
  import { SortType } from '@/types/files';
14
16
 
15
17
  import EmptyStatus from './EmptyStatus';
16
18
  import FileListItem, { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './FileListItem';
17
19
  import FileSkeleton from './FileSkeleton';
20
+ import MasonryItemWrapper from './MasonryFileItem/MasonryItemWrapper';
21
+ import MasonrySkeleton from './MasonrySkeleton';
18
22
  import ToolBar from './ToolBar';
23
+ import { ViewMode } from './ToolBar/ViewSwitcher';
19
24
  import { useCheckTaskStatus } from './useCheckTaskStatus';
20
25
 
21
26
  const useStyles = createStyles(({ css, token, isDarkMode }) => ({
@@ -47,6 +52,35 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
47
52
  const [selectFileIds, setSelectedFileIds] = useState<string[]>([]);
48
53
  const [viewConfig, setViewConfig] = useState({ showFilesInKnowledgeBase: false });
49
54
 
55
+ const viewMode = useGlobalStore((s) => s.status.fileManagerViewMode || 'list') as ViewMode;
56
+ const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
57
+ const setViewMode = (mode: ViewMode) => updateSystemStatus({ fileManagerViewMode: mode });
58
+
59
+ const [columnCount, setColumnCount] = useState(4);
60
+
61
+ // Update column count based on window size
62
+ const updateColumnCount = () => {
63
+ const width = window.innerWidth;
64
+ if (width < 768) {
65
+ setColumnCount(2);
66
+ } else if (width < 1024) {
67
+ setColumnCount(3);
68
+ } else if (width < 1440) {
69
+ setColumnCount(4);
70
+ } else {
71
+ setColumnCount(5);
72
+ }
73
+ };
74
+
75
+ // Set initial column count and listen for resize
76
+ React.useEffect(() => {
77
+ if (viewMode === 'masonry') {
78
+ updateColumnCount();
79
+ window.addEventListener('resize', updateColumnCount);
80
+ return () => window.removeEventListener('resize', updateColumnCount);
81
+ }
82
+ }, [viewMode]);
83
+
50
84
  const [query] = useQueryState('q', {
51
85
  clearOnDefault: true,
52
86
  });
@@ -73,6 +107,17 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
73
107
 
74
108
  useCheckTaskStatus(data);
75
109
 
110
+ // Clean up selected files that no longer exist in the data
111
+ React.useEffect(() => {
112
+ if (data && selectFileIds.length > 0) {
113
+ const validFileIds = new Set(data.map((item) => item?.id).filter(Boolean));
114
+ const filteredSelection = selectFileIds.filter((id) => validFileIds.has(id));
115
+ if (filteredSelection.length !== selectFileIds.length) {
116
+ setSelectedFileIds(filteredSelection);
117
+ }
118
+ }
119
+ }, [data]);
120
+
76
121
  return !isLoading && data?.length === 0 ? (
77
122
  <EmptyStatus knowledgeBaseId={knowledgeBaseId} showKnowledgeBase={!knowledgeBaseId} />
78
123
  ) : (
@@ -83,28 +128,36 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
83
128
  key={selectFileIds.join('-')}
84
129
  knowledgeBaseId={knowledgeBaseId}
85
130
  onConfigChange={setViewConfig}
131
+ onViewChange={setViewMode}
86
132
  selectCount={selectFileIds.length}
87
133
  selectFileIds={selectFileIds}
88
134
  setSelectedFileIds={setSelectedFileIds}
89
135
  showConfig={!knowledgeBaseId}
90
136
  total={data?.length}
91
137
  totalFileIds={data?.map((item) => item.id) || []}
138
+ viewMode={viewMode}
92
139
  />
93
- <Flexbox align={'center'} className={styles.header} horizontal paddingInline={8}>
94
- <Flexbox className={styles.headerItem} flex={1} style={{ paddingInline: 32 }}>
95
- {t('FileManager.title.title')}
140
+ {viewMode === 'list' && (
141
+ <Flexbox align={'center'} className={styles.header} horizontal paddingInline={8}>
142
+ <Flexbox className={styles.headerItem} flex={1} style={{ paddingInline: 32 }}>
143
+ {t('FileManager.title.title')}
144
+ </Flexbox>
145
+ <Flexbox className={styles.headerItem} width={FILE_DATE_WIDTH}>
146
+ {t('FileManager.title.createdAt')}
147
+ </Flexbox>
148
+ <Flexbox className={styles.headerItem} width={FILE_SIZE_WIDTH}>
149
+ {t('FileManager.title.size')}
150
+ </Flexbox>
96
151
  </Flexbox>
97
- <Flexbox className={styles.headerItem} width={FILE_DATE_WIDTH}>
98
- {t('FileManager.title.createdAt')}
99
- </Flexbox>
100
- <Flexbox className={styles.headerItem} width={FILE_SIZE_WIDTH}>
101
- {t('FileManager.title.size')}
102
- </Flexbox>
103
- </Flexbox>
152
+ )}
104
153
  </Flexbox>
105
154
  {isLoading ? (
106
- <FileSkeleton />
107
- ) : (
155
+ viewMode === 'masonry' ? (
156
+ <MasonrySkeleton columnCount={columnCount} />
157
+ ) : (
158
+ <FileSkeleton />
159
+ )
160
+ ) : viewMode === 'list' ? (
108
161
  <Virtuoso
109
162
  components={{
110
163
  Footer: () => (
@@ -135,6 +188,23 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
135
188
  )}
136
189
  style={{ flex: 1 }}
137
190
  />
191
+ ) : (
192
+ <div style={{ flex: 1, overflow: 'hidden' }}>
193
+ <div style={{ height: '100%', overflowY: 'auto' }}>
194
+ <div style={{ paddingBlockEnd: 64, paddingBlockStart: 12, paddingInline: 24 }}>
195
+ <VirtuosoMasonry
196
+ ItemContent={MasonryItemWrapper}
197
+ columnCount={columnCount}
198
+ context={{ knowledgeBaseId, selectFileIds, setSelectedFileIds }}
199
+ data={data || []}
200
+ key={`masonry-${query || 'all'}-${data?.length || 0}`}
201
+ style={{
202
+ gap: '16px',
203
+ }}
204
+ />
205
+ </div>
206
+ </div>
207
+ </div>
138
208
  )}
139
209
  </Flexbox>
140
210
  );
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { SearchBar } from '@lobehub/ui';
4
4
  import { useQueryState } from 'nuqs';
5
- import { memo, useState } from 'react';
5
+ import { memo, useEffect, useState } from 'react';
6
6
  import { useTranslation } from 'react-i18next';
7
7
 
8
8
  import { useUserStore } from '@/store/user';
@@ -14,10 +14,15 @@ const FilesSearchBar = memo<{ mobile?: boolean }>(({ mobile }) => {
14
14
  const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.Search));
15
15
  const [keywords, setKeywords] = useState<string>('');
16
16
 
17
- const [, setQuery] = useQueryState('q', {
17
+ const [query, setQuery] = useQueryState('q', {
18
18
  clearOnDefault: true,
19
19
  });
20
20
 
21
+ // Sync local state with URL query parameter
22
+ useEffect(() => {
23
+ setKeywords(query || '');
24
+ }, [query]);
25
+
21
26
  return (
22
27
  <SearchBar
23
28
  allowClear
@@ -22,7 +22,7 @@ const Preview = memo<FieldType & { title?: string }>(
22
22
  ({ title, withSystemRole, withBackground, withFooter }) => {
23
23
  const [model, plugins, systemRole] = useAgentStore((s) => [
24
24
  agentSelectors.currentAgentModel(s),
25
- agentSelectors.currentAgentPlugins(s),
25
+ agentSelectors.displayableAgentPlugins(s),
26
26
  agentSelectors.currentAgentSystemRole(s),
27
27
  ]);
28
28
  const [isInbox, description, avatar, backgroundColor] = useSessionStore((s) => [
@@ -0,0 +1,361 @@
1
+ import { LoadingOutlined } from '@ant-design/icons';
2
+ import { Button } from '@lobehub/ui';
3
+ import { Input, Modal, Spin } from 'antd';
4
+ import { createStyles } from 'antd-style';
5
+ import { ChevronLeft, ChevronRight, Expand, FileText } from 'lucide-react';
6
+ import { memo, useState } from 'react';
7
+ import { useTranslation } from 'react-i18next';
8
+ import { Flexbox } from 'react-layout-kit';
9
+ import { Document, Page, pdfjs } from 'react-pdf';
10
+
11
+ import { useIsMobile } from '@/hooks/useIsMobile';
12
+
13
+ import { useContainerStyles } from '../style';
14
+
15
+ // Set PDF.js worker
16
+ pdfjs.GlobalWorkerOptions.workerSrc = `https://registry.npmmirror.com/pdfjs-dist/${pdfjs.version}/files/build/pdf.worker.min.mjs`;
17
+
18
+ const useStyles = createStyles(({ css }) => ({
19
+ containerWrapper: css`
20
+ position: relative;
21
+ width: 100%;
22
+ height: 100%;
23
+ `,
24
+ documentLoading: css`
25
+ display: flex;
26
+ flex-direction: column;
27
+ align-items: center;
28
+ justify-content: center;
29
+
30
+ height: 100%;
31
+ padding: 20px;
32
+ `,
33
+ emptyState: css`
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+
38
+ height: 100%;
39
+
40
+ color: #666;
41
+ `,
42
+ expandButton: css`
43
+ position: absolute;
44
+ z-index: 1000;
45
+ inset-block-start: 20px;
46
+ inset-inline-end: 20px;
47
+ `,
48
+ footerNavigation: css`
49
+ position: absolute;
50
+ z-index: 10;
51
+ inset-block-end: 0;
52
+ inset-inline: 0 0;
53
+
54
+ padding: 12px;
55
+ border-block-start: 1px solid rgba(0, 0, 0, 10%);
56
+
57
+ background: rgba(255, 255, 255, 90%);
58
+ backdrop-filter: blur(8px);
59
+ `,
60
+ fullscreenButton: css`
61
+ border-color: white;
62
+ color: white;
63
+ `,
64
+ fullscreenContent: css`
65
+ display: flex;
66
+ align-items: flex-start;
67
+ justify-content: center;
68
+
69
+ min-height: 100%;
70
+ padding: 20px;
71
+ `,
72
+ fullscreenModal: css`
73
+ position: relative;
74
+ overflow: auto;
75
+ height: 90vh;
76
+ `,
77
+ fullscreenNavigation: css`
78
+ position: fixed;
79
+ z-index: 1001;
80
+ inset-block-end: 20px;
81
+ inset-inline-start: 50%;
82
+ transform: translateX(-50%);
83
+
84
+ padding-block: 12px;
85
+ padding-inline: 20px;
86
+ border-radius: 8px;
87
+
88
+ background: rgba(0, 0, 0, 70%);
89
+ backdrop-filter: blur(8px);
90
+ `,
91
+ fullscreenPageInput: css`
92
+ width: 60px;
93
+ text-align: center;
94
+ `,
95
+ fullscreenPageText: css`
96
+ min-width: 20px;
97
+ font-size: 14px;
98
+ color: white;
99
+ `,
100
+ loadingState: css`
101
+ display: flex;
102
+ flex-direction: column;
103
+ align-items: center;
104
+ justify-content: center;
105
+
106
+ height: 100%;
107
+ `,
108
+ loadingText: css`
109
+ margin-block-start: 8px;
110
+ color: #666;
111
+ `,
112
+ pageInput: css`
113
+ width: 50px;
114
+ text-align: center;
115
+ `,
116
+ pageNumberText: css`
117
+ font-size: 12px;
118
+ color: #666;
119
+ `,
120
+ previewContainer: css`
121
+ display: flex;
122
+ align-items: flex-start;
123
+ justify-content: center;
124
+ padding: 12px;
125
+ `,
126
+ }));
127
+
128
+ interface PdfPreviewProps {
129
+ loading: boolean;
130
+ onGeneratePdf?: () => void;
131
+ pdfData: string | null;
132
+ }
133
+
134
+ const PdfPreview = memo<PdfPreviewProps>(({ loading, pdfData, onGeneratePdf }) => {
135
+ const { styles } = useContainerStyles();
136
+ const { styles: localStyles } = useStyles();
137
+ const { t } = useTranslation('chat');
138
+ const isMobile = useIsMobile();
139
+
140
+ // Page navigation state
141
+ const [numPages, setNumPages] = useState<number>(0);
142
+ const [pageNumber, setPageNumber] = useState<number>(1);
143
+ const [fullscreenOpen, setFullscreenOpen] = useState(false);
144
+ const [fullscreenPageNumber, setFullscreenPageNumber] = useState<number>(1);
145
+
146
+ const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
147
+ setNumPages(numPages);
148
+ setPageNumber(1);
149
+ };
150
+
151
+ const goToPrevPage = () => {
152
+ if (pageNumber > 1) {
153
+ setPageNumber(pageNumber - 1);
154
+ }
155
+ };
156
+
157
+ const goToNextPage = () => {
158
+ if (pageNumber < numPages) {
159
+ setPageNumber(pageNumber + 1);
160
+ }
161
+ };
162
+
163
+ const goToPage = (page: number) => {
164
+ if (page >= 1 && page <= numPages) {
165
+ setPageNumber(page);
166
+ }
167
+ };
168
+
169
+ const handleFullscreen = () => {
170
+ if (pdfData) {
171
+ setFullscreenPageNumber(pageNumber);
172
+ setFullscreenOpen(true);
173
+ }
174
+ };
175
+
176
+ const goToFullscreenPrevPage = () => {
177
+ if (fullscreenPageNumber > 1) {
178
+ setFullscreenPageNumber(fullscreenPageNumber - 1);
179
+ }
180
+ };
181
+
182
+ const goToFullscreenNextPage = () => {
183
+ if (fullscreenPageNumber < numPages) {
184
+ setFullscreenPageNumber(fullscreenPageNumber + 1);
185
+ }
186
+ };
187
+
188
+ const goToFullscreenPage = (page: number) => {
189
+ if (page >= 1 && page <= numPages) {
190
+ setFullscreenPageNumber(page);
191
+ }
192
+ };
193
+
194
+ if (loading) {
195
+ return (
196
+ <div className={styles.preview} style={{ padding: 12 }}>
197
+ <div className={localStyles.loadingState}>
198
+ <Spin indicator={<LoadingOutlined spin style={{ fontSize: 24 }} />} />
199
+ <div className={localStyles.loadingText}>{t('shareModal.generatingPdf')}</div>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ if (!pdfData) {
206
+ return (
207
+ <div className={styles.preview} style={{ padding: 12 }}>
208
+ <div className={localStyles.emptyState}>
209
+ <Button icon={<FileText size={20} />} onClick={onGeneratePdf} size="large" type="primary">
210
+ {t('shareModal.generatePdf', { defaultValue: '生成 PDF' })}
211
+ </Button>
212
+ </div>
213
+ </div>
214
+ );
215
+ }
216
+
217
+ // Convert base64 to data URI
218
+ const pdfDataUri = `data:application/pdf;base64,${pdfData}`;
219
+
220
+ return (
221
+ <>
222
+ <div className={localStyles.containerWrapper}>
223
+ {pdfData && (
224
+ <Button
225
+ className={localStyles.expandButton}
226
+ icon={<Expand size={16} />}
227
+ onClick={handleFullscreen}
228
+ size="small"
229
+ type="text"
230
+ />
231
+ )}
232
+
233
+ <div className={`${styles.preview} ${localStyles.previewContainer}`}>
234
+ <Document
235
+ file={pdfDataUri}
236
+ loading={
237
+ <div className={localStyles.documentLoading}>
238
+ <Spin />
239
+ <div className={localStyles.loadingText}>
240
+ {t('shareModal.loadingPdf', { defaultValue: 'Loading PDF...' })}
241
+ </div>
242
+ </div>
243
+ }
244
+ onLoadSuccess={onDocumentLoadSuccess}
245
+ >
246
+ <Page
247
+ pageNumber={pageNumber}
248
+ renderAnnotationLayer={false}
249
+ renderTextLayer={false}
250
+ width={isMobile ? 300 : 400}
251
+ />
252
+ </Document>
253
+ </div>
254
+
255
+ {/* 页脚导航 */}
256
+ {pdfData && numPages > 1 && (
257
+ <div className={localStyles.footerNavigation}>
258
+ <Flexbox align="center" gap={8} horizontal justify="center">
259
+ <Button
260
+ disabled={pageNumber <= 1}
261
+ icon={<ChevronLeft size={16} />}
262
+ onClick={goToPrevPage}
263
+ size="small"
264
+ type="text"
265
+ />
266
+ <Flexbox align="center" gap={4} horizontal>
267
+ <Input
268
+ className={localStyles.pageInput}
269
+ max={numPages}
270
+ min={1}
271
+ onChange={(e) => {
272
+ const value = parseInt(e.target.value);
273
+ if (!isNaN(value)) goToPage(value);
274
+ }}
275
+ size="small"
276
+ type="number"
277
+ value={pageNumber}
278
+ />
279
+ <span className={localStyles.pageNumberText}>/ {numPages}</span>
280
+ </Flexbox>
281
+ <Button
282
+ disabled={pageNumber >= numPages}
283
+ icon={<ChevronRight size={16} />}
284
+ onClick={goToNextPage}
285
+ size="small"
286
+ type="text"
287
+ />
288
+ </Flexbox>
289
+ </div>
290
+ )}
291
+ </div>
292
+
293
+ {/* 全屏模态框 */}
294
+ <Modal
295
+ centered
296
+ footer={null}
297
+ onCancel={() => setFullscreenOpen(false)}
298
+ open={fullscreenOpen}
299
+ styles={{
300
+ body: { padding: 0 },
301
+ content: { padding: 0 },
302
+ }}
303
+ width="95vw"
304
+ >
305
+ <div className={localStyles.fullscreenModal}>
306
+ <div className={localStyles.fullscreenContent}>
307
+ <Document file={pdfDataUri} onLoadSuccess={onDocumentLoadSuccess}>
308
+ <Page
309
+ pageNumber={fullscreenPageNumber}
310
+ renderAnnotationLayer={false}
311
+ renderTextLayer={false}
312
+ width={Math.min(window.innerWidth * 0.8, 1000)}
313
+ />
314
+ </Document>
315
+ </div>
316
+
317
+ {/* 全屏模式下的导航 */}
318
+ {numPages > 1 && (
319
+ <div className={localStyles.fullscreenNavigation}>
320
+ <Flexbox align="center" gap={12} horizontal>
321
+ <Button
322
+ className={localStyles.fullscreenButton}
323
+ disabled={fullscreenPageNumber <= 1}
324
+ icon={<ChevronLeft size={16} />}
325
+ onClick={goToFullscreenPrevPage}
326
+ size="small"
327
+ type="text"
328
+ />
329
+ <Flexbox align="center" gap={8} horizontal>
330
+ <Input
331
+ className={localStyles.fullscreenPageInput}
332
+ max={numPages}
333
+ min={1}
334
+ onChange={(e) => {
335
+ const value = parseInt(e.target.value);
336
+ if (!isNaN(value)) goToFullscreenPage(value);
337
+ }}
338
+ size="small"
339
+ type="number"
340
+ value={fullscreenPageNumber}
341
+ />
342
+ <span className={localStyles.fullscreenPageText}>/ {numPages}</span>
343
+ </Flexbox>
344
+ <Button
345
+ className={localStyles.fullscreenButton}
346
+ disabled={fullscreenPageNumber >= numPages}
347
+ icon={<ChevronRight size={16} />}
348
+ onClick={goToFullscreenNextPage}
349
+ size="small"
350
+ type="text"
351
+ />
352
+ </Flexbox>
353
+ </div>
354
+ )}
355
+ </div>
356
+ </Modal>
357
+ </>
358
+ );
359
+ });
360
+
361
+ export default PdfPreview;