@lobehub/chat 1.85.7 → 1.85.9

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 (32) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/changelog/v1.json +21 -0
  3. package/docker-compose/local/init_data.json +2 -2
  4. package/package.json +2 -2
  5. package/packages/file-loaders/package.json +1 -1
  6. package/packages/file-loaders/test/setup.ts +1 -1
  7. package/src/components/Thinking/index.tsx +38 -22
  8. package/src/features/ChatInput/ActionBar/Knowledge/index.tsx +2 -1
  9. package/src/features/ChatInput/ActionBar/Tools/index.tsx +3 -2
  10. package/src/features/Conversation/components/ChatItem/index.tsx +2 -1
  11. package/src/features/DataImporter/index.tsx +0 -1
  12. package/src/features/Portal/Artifacts/Body/index.tsx +4 -1
  13. package/src/features/Portal/Artifacts/Header.tsx +2 -6
  14. package/src/features/Portal/FilePreview/Header.tsx +1 -1
  15. package/src/features/Portal/Plugins/Header.tsx +2 -2
  16. package/src/features/Portal/components/Header.tsx +1 -0
  17. package/src/features/User/UserPanel/LangButton.tsx +3 -8
  18. package/src/features/User/UserPanel/PanelContent.tsx +1 -1
  19. package/src/features/User/UserPanel/ThemeButton.tsx +1 -7
  20. package/src/middleware.ts +20 -9
  21. package/src/server/routers/lambda/__tests__/importer.test.ts +3 -0
  22. package/src/server/routers/lambda/importer.ts +10 -2
  23. package/src/services/__tests__/upload.test.ts +4 -6
  24. package/src/services/import/server.ts +20 -71
  25. package/src/services/ragEval.ts +1 -1
  26. package/src/services/upload.ts +52 -18
  27. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +14 -2
  28. package/src/store/chat/slices/builtinTool/actions/dalle.test.ts +2 -0
  29. package/src/store/chat/slices/message/selectors.ts +5 -0
  30. package/src/store/file/slices/upload/action.ts +14 -27
  31. package/src/utils/fetch/__tests__/fetchSSE.test.ts +5 -18
  32. package/src/utils/fetch/fetchSSE.ts +62 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.85.9](https://github.com/lobehub/lobe-chat/compare/v1.85.8...v1.85.9)
6
+
7
+ <sup>Released on **2025-05-14**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **misc**: Redirect unauthorized next-auth user to signin page.
12
+
13
+ #### 💄 Styles
14
+
15
+ - **misc**: Improve smoothing on completion.
16
+
17
+ <br/>
18
+
19
+ <details>
20
+ <summary><kbd>Improvements and Fixes</kbd></summary>
21
+
22
+ #### What's fixed
23
+
24
+ - **misc**: Redirect unauthorized next-auth user to signin page, closes [#7813](https://github.com/lobehub/lobe-chat/issues/7813) ([6160784](https://github.com/lobehub/lobe-chat/commit/6160784))
25
+
26
+ #### Styles
27
+
28
+ - **misc**: Improve smoothing on completion, closes [#7833](https://github.com/lobehub/lobe-chat/issues/7833) ([6434686](https://github.com/lobehub/lobe-chat/commit/6434686))
29
+
30
+ </details>
31
+
32
+ <div align="right">
33
+
34
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
35
+
36
+ </div>
37
+
38
+ ### [Version 1.85.8](https://github.com/lobehub/lobe-chat/compare/v1.85.7...v1.85.8)
39
+
40
+ <sup>Released on **2025-05-11**</sup>
41
+
42
+ #### 🐛 Bug Fixes
43
+
44
+ - **misc**: Fix config import issue in the desktop version.
45
+
46
+ <br/>
47
+
48
+ <details>
49
+ <summary><kbd>Improvements and Fixes</kbd></summary>
50
+
51
+ #### What's fixed
52
+
53
+ - **misc**: Fix config import issue in the desktop version, closes [#7800](https://github.com/lobehub/lobe-chat/issues/7800) ([2cb8635](https://github.com/lobehub/lobe-chat/commit/2cb8635))
54
+
55
+ </details>
56
+
57
+ <div align="right">
58
+
59
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
60
+
61
+ </div>
62
+
5
63
  ### [Version 1.85.7](https://github.com/lobehub/lobe-chat/compare/v1.85.6...v1.85.7)
6
64
 
7
65
  <sup>Released on **2025-05-11**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,25 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "fixes": [
5
+ "Redirect unauthorized next-auth user to signin page."
6
+ ],
7
+ "improvements": [
8
+ "Improve smoothing on completion."
9
+ ]
10
+ },
11
+ "date": "2025-05-14",
12
+ "version": "1.85.9"
13
+ },
14
+ {
15
+ "children": {
16
+ "fixes": [
17
+ "Fix config import issue in the desktop version."
18
+ ]
19
+ },
20
+ "date": "2025-05-11",
21
+ "version": "1.85.8"
22
+ },
2
23
  {
3
24
  "children": {
4
25
  "fixes": [
@@ -42,7 +42,7 @@
42
42
  "cert": "cert-built-in",
43
43
  "headerHtml": "",
44
44
  "enablePassword": true,
45
- "enableSignUp": true,
45
+ "enableSignUp": false,
46
46
  "enableSigninSession": false,
47
47
  "enableAutoSignin": false,
48
48
  "enableCodeSignin": false,
@@ -1235,4 +1235,4 @@
1235
1235
  }
1236
1236
  ],
1237
1237
  "webhooks": []
1238
- }
1238
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.85.7",
3
+ "version": "1.85.9",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -150,7 +150,7 @@
150
150
  "@lobehub/chat-plugins-gateway": "^1.9.0",
151
151
  "@lobehub/icons": "^2.0.0",
152
152
  "@lobehub/tts": "^2.0.0",
153
- "@lobehub/ui": "^2.0.10",
153
+ "@lobehub/ui": "^2.0.13",
154
154
  "@modelcontextprotocol/sdk": "^1.11.0",
155
155
  "@neondatabase/serverless": "^1.0.0",
156
156
  "@next/third-parties": "^15.3.0",
@@ -26,6 +26,7 @@
26
26
  "dependencies": {
27
27
  "@langchain/community": "^0.3.41",
28
28
  "@langchain/core": "^0.3.45",
29
+ "@napi-rs/canvas": "^0.1.70",
29
30
  "@xmldom/xmldom": "^0.9.8",
30
31
  "concat-stream": "^2.0.0",
31
32
  "mammoth": "^1.8.0",
@@ -37,7 +38,6 @@
37
38
  "devDependencies": {
38
39
  "@types/concat-stream": "^2.0.3",
39
40
  "@types/yauzl": "^2.10.3",
40
- "canvas": "^3.1.0",
41
41
  "typescript": "^5"
42
42
  },
43
43
  "peerDependencies": {
@@ -1,5 +1,5 @@
1
1
  // Polyfill DOMMatrix for pdfjs-dist in Node.js environment
2
- import { DOMMatrix } from 'canvas';
2
+ import { DOMMatrix } from '@napi-rs/canvas';
3
3
 
4
4
  if (typeof global.DOMMatrix === 'undefined') {
5
5
  // @ts-ignore
@@ -1,4 +1,4 @@
1
- import { CopyButton, Icon, Markdown } from '@lobehub/ui';
1
+ import { ActionIcon, CopyButton, Icon, Markdown } from '@lobehub/ui';
2
2
  import { createStyles } from 'antd-style';
3
3
  import { AnimatePresence, motion } from 'framer-motion';
4
4
  import { AtomIcon, ChevronDown, ChevronRight } from 'lucide-react';
@@ -9,21 +9,32 @@ import { Flexbox } from 'react-layout-kit';
9
9
 
10
10
  import { CitationItem } from '@/types/message';
11
11
 
12
- const useStyles = createStyles(({ css, token, isDarkMode }) => ({
12
+ const useStyles = createStyles(({ css, token }) => ({
13
13
  container: css`
14
- width: fit-content;
15
- padding-block: 4px;
16
- padding-inline: 8px;
17
- border-radius: 6px;
18
-
14
+ overflow: hidden;
15
+ border-radius: ${token.borderRadius}px;
19
16
  color: ${token.colorTextTertiary};
17
+ transition: all 0.2s ${token.motionEaseOut};
18
+ `,
19
+ expand: css`
20
+ color: ${token.colorTextSecondary};
21
+ background: ${token.colorFillTertiary};
22
+ `,
23
+
24
+ header: css`
25
+ padding-block: 4px;
26
+ padding-inline: 8px 4px;
27
+ transition: background 0.2s ${token.motionEaseOut};
28
+ transition: all 0.2s ${token.motionEaseOut};
20
29
 
21
30
  &:hover {
22
- background: ${isDarkMode ? token.colorFillQuaternary : token.colorFillTertiary};
31
+ background: ${token.colorFillQuaternary};
23
32
  }
24
33
  `,
25
- expand: css`
26
- background: ${isDarkMode ? token.colorFillQuaternary : token.colorFillTertiary} !important;
34
+
35
+ headerExpand: css`
36
+ color: ${token.colorTextSecondary};
37
+ background: ${token.colorFillQuaternary};
27
38
  `,
28
39
  shinyText: css`
29
40
  color: ${rgba(token.colorText, 0.45)};
@@ -70,7 +81,7 @@ interface ThinkingProps {
70
81
 
71
82
  const Thinking = memo<ThinkingProps>(({ content, duration, thinking, style, citations }) => {
72
83
  const { t } = useTranslation(['components', 'common']);
73
- const { styles, cx } = useStyles();
84
+ const { styles, cx, theme } = useStyles();
74
85
 
75
86
  const [showDetail, setShowDetail] = useState(false);
76
87
 
@@ -79,8 +90,13 @@ const Thinking = memo<ThinkingProps>(({ content, duration, thinking, style, cita
79
90
  }, [thinking]);
80
91
 
81
92
  return (
82
- <Flexbox className={cx(styles.container, showDetail && styles.expand)} gap={16} style={style}>
93
+ <Flexbox
94
+ className={cx(styles.container, showDetail && styles.expand)}
95
+ style={style}
96
+ width={'100%'}
97
+ >
83
98
  <Flexbox
99
+ className={cx(styles.header, showDetail && styles.headerExpand)}
84
100
  distribution={'space-between'}
85
101
  flex={1}
86
102
  gap={8}
@@ -89,17 +105,18 @@ const Thinking = memo<ThinkingProps>(({ content, duration, thinking, style, cita
89
105
  setShowDetail(!showDetail);
90
106
  }}
91
107
  style={{ cursor: 'pointer' }}
108
+ width={'100%'}
92
109
  >
93
110
  {thinking ? (
94
- <Flexbox align={'center'} gap={8} horizontal>
95
- <Icon icon={AtomIcon} />
111
+ <Flexbox align={'center'} gap={8} horizontal width={'100%'}>
112
+ <Icon color={theme.purple} icon={AtomIcon} />
96
113
  <Flexbox className={styles.shinyText} horizontal>
97
114
  {t('Thinking.thinking')}
98
115
  </Flexbox>
99
116
  </Flexbox>
100
117
  ) : (
101
- <Flexbox align={'center'} gap={8} horizontal>
102
- <Icon icon={AtomIcon} />
118
+ <Flexbox align={'center'} gap={8} horizontal width={'100%'}>
119
+ <Icon color={showDetail ? theme.purple : undefined} icon={AtomIcon} />
103
120
  <Flexbox>
104
121
  {!duration
105
122
  ? t('Thinking.thoughtWithDuration')
@@ -117,28 +134,27 @@ const Thinking = memo<ThinkingProps>(({ content, duration, thinking, style, cita
117
134
  <CopyButton content={content} size={'small'} title={t('copy', { ns: 'common' })} />
118
135
  </div>
119
136
  )}
120
- <Icon icon={showDetail ? ChevronDown : ChevronRight} />
137
+ <ActionIcon icon={showDetail ? ChevronDown : ChevronRight} size={'small'} />
121
138
  </Flexbox>
122
139
  </Flexbox>
123
-
124
140
  <AnimatePresence initial={false}>
125
141
  {showDetail && (
126
142
  <motion.div
127
143
  animate="open"
128
144
  exit="collapsed"
129
145
  initial="collapsed"
130
- style={{ overflow: 'hidden' }}
146
+ style={{ overflow: 'hidden', padding: 12 }}
131
147
  transition={{
132
148
  duration: 0.2,
133
149
  ease: [0.4, 0, 0.2, 1], // 使用 ease-out 缓动函数
134
150
  }}
135
151
  variants={{
136
- collapsed: { height: 0, opacity: 0, width: 'auto' },
137
- open: { height: 'auto', opacity: 1, width: 'auto' },
152
+ collapsed: { opacity: 0, width: 'auto' },
153
+ open: { opacity: 1, width: 'auto' },
138
154
  }}
139
155
  >
140
156
  {typeof content === 'string' ? (
141
- <Markdown citations={citations} variant={'chat'}>
157
+ <Markdown animated={thinking} citations={citations} variant={'chat'}>
142
158
  {content}
143
159
  </Markdown>
144
160
  ) : (
@@ -41,7 +41,8 @@ const Knowledge = memo(() => {
41
41
  const content = (
42
42
  <Action
43
43
  dropdown={{
44
- maxWidth: 320,
44
+ maxHeight: 500,
45
+ maxWidth: 480,
45
46
  menu: { items },
46
47
  minWidth: 240,
47
48
  }}
@@ -31,9 +31,10 @@ const Tools = memo(() => {
31
31
  <Suspense fallback={<Action disabled icon={Blocks} title={t('tools.title')} />}>
32
32
  <Action
33
33
  dropdown={{
34
- maxWidth: 320,
34
+ maxHeight: 500,
35
+ maxWidth: 480,
35
36
  menu: { items },
36
- minWidth: 240,
37
+ minWidth: 320,
37
38
  }}
38
39
  icon={Blocks}
39
40
  loading={updating}
@@ -171,6 +171,7 @@ const Item = memo<ChatListItemProps>(
171
171
 
172
172
  const markdownProps = useMemo(
173
173
  () => ({
174
+ animated: generating,
174
175
  citations: item?.role === 'user' ? undefined : item?.search?.citations,
175
176
  components,
176
177
  customRender: markdownCustomRender,
@@ -186,7 +187,7 @@ const Item = memo<ChatListItemProps>(
186
187
  // if the citations's url and title are all the same, we should not show the citations
187
188
  item?.search?.citations.every((item) => item.title !== item.url),
188
189
  }),
189
- [components, markdownCustomRender, item?.role, item?.search],
190
+ [generating, components, markdownCustomRender, item?.role, item?.search],
190
191
  );
191
192
 
192
193
  const onChange = useCallback((value: string) => updateMessageContent(id, value), [id]);
@@ -64,7 +64,6 @@ const DataImporter = memo<DataImporterProps>(({ children, onFinishImport }) => {
64
64
 
65
65
  const { type, ...res } = importResults;
66
66
 
67
- console.log(res);
68
67
  if (type === 'settings') return;
69
68
 
70
69
  return Object.entries(res)
@@ -79,7 +79,10 @@ const ArtifactsUI = memo(() => {
79
79
  style={{ overflow: 'hidden' }}
80
80
  >
81
81
  {showCode ? (
82
- <Highlighter language={language || 'txt'} style={{ maxHeight: '100%', overflow: 'hidden' }}>
82
+ <Highlighter
83
+ language={language || 'txt'}
84
+ style={{ fontSize: 12, height: '100%', overflow: 'auto' }}
85
+ >
83
86
  {artifactContent}
84
87
  </Highlighter>
85
88
  ) : (
@@ -33,12 +33,8 @@ const Header = () => {
33
33
  return (
34
34
  <Flexbox align={'center'} flex={1} gap={12} horizontal justify={'space-between'} width={'100%'}>
35
35
  <Flexbox align={'center'} gap={4} horizontal>
36
- <ActionIcon icon={ArrowLeft} onClick={() => closeArtifact()} />
37
- <Typography.Text
38
- className={cx(oneLineEllipsis)}
39
- style={{ fontSize: 16 }}
40
- type={'secondary'}
41
- >
36
+ <ActionIcon icon={ArrowLeft} onClick={() => closeArtifact()} size={'small'} />
37
+ <Typography.Text className={cx(oneLineEllipsis)} type={'secondary'}>
42
38
  {artifactTitle}
43
39
  </Typography.Text>
44
40
  </Flexbox>
@@ -20,7 +20,7 @@ const Header = () => {
20
20
 
21
21
  return (
22
22
  <Flexbox align={'center'} gap={4} horizontal>
23
- <ActionIcon icon={ArrowLeft} onClick={() => closeFilePreview()} />
23
+ <ActionIcon icon={ArrowLeft} onClick={() => closeFilePreview()} size={'small'} />
24
24
 
25
25
  {isLoading ? (
26
26
  <Skeleton.Button active style={{ height: 28 }} />
@@ -25,7 +25,7 @@ const Header = () => {
25
25
  if (toolUIIdentifier === WebBrowsingManifest.identifier) {
26
26
  return (
27
27
  <Flexbox align={'center'} gap={8} horizontal>
28
- <ActionIcon icon={ArrowLeft} onClick={() => closeToolUI()} />
28
+ <ActionIcon icon={ArrowLeft} onClick={() => closeToolUI()} size={'small'} />
29
29
  <Icon icon={Globe} size={16} />
30
30
  <Typography.Text style={{ fontSize: 16 }} type={'secondary'}>
31
31
  {t('search.title')}
@@ -35,7 +35,7 @@ const Header = () => {
35
35
  }
36
36
  return (
37
37
  <Flexbox align={'center'} gap={4} horizontal>
38
- <ActionIcon icon={ArrowLeft} onClick={() => closeToolUI()} />
38
+ <ActionIcon icon={ArrowLeft} onClick={() => closeToolUI()} size={'small'} />
39
39
  <PluginAvatar identifier={toolUIIdentifier} size={28} />
40
40
  <Typography.Text style={{ fontSize: 16 }} type={'secondary'}>
41
41
  {pluginTitle}
@@ -18,6 +18,7 @@ const Header = memo<{ title: ReactNode }>(({ title }) => {
18
18
  onClick={() => {
19
19
  toggleInspector(false);
20
20
  }}
21
+ size={'small'}
21
22
  />
22
23
  }
23
24
  style={{ paddingBlock: 8, paddingInline: 8 }}
@@ -1,6 +1,5 @@
1
1
  import { ActionIcon } from '@lobehub/ui';
2
2
  import { Popover, type PopoverProps } from 'antd';
3
- import { useTheme } from 'antd-style';
4
3
  import { Languages } from 'lucide-react';
5
4
  import { memo, useMemo } from 'react';
6
5
  import { useTranslation } from 'react-i18next';
@@ -12,8 +11,6 @@ import { globalGeneralSelectors } from '@/store/global/selectors';
12
11
  import { LocaleMode } from '@/types/locale';
13
12
 
14
13
  const LangButton = memo<{ placement?: PopoverProps['placement'] }>(({ placement = 'right' }) => {
15
- const theme = useTheme();
16
-
17
14
  const [language, switchLocale] = useGlobalStore((s) => [
18
15
  globalGeneralSelectors.language(s),
19
16
  s.switchLocale,
@@ -48,16 +45,14 @@ const LangButton = memo<{ placement?: PopoverProps['placement'] }>(({ placement
48
45
  placement={placement}
49
46
  styles={{
50
47
  body: {
48
+ maxHeight: 360,
49
+ overflow: 'auto',
51
50
  padding: 0,
52
51
  },
53
52
  }}
54
53
  trigger={['click', 'hover']}
55
54
  >
56
- <ActionIcon
57
- icon={Languages}
58
- size={{ blockSize: 32, size: 16 }}
59
- style={{ border: `1px solid ${theme.colorFillSecondary}` }}
60
- />
55
+ <ActionIcon icon={Languages} size={{ blockSize: 32, size: 16 }} />
61
56
  </Popover>
62
57
  );
63
58
  });
@@ -63,7 +63,7 @@ const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
63
63
  ) : (
64
64
  <BrandWatermark />
65
65
  )}
66
- <Flexbox align={'center'} flex={'none'} gap={6} horizontal>
66
+ <Flexbox align={'center'} flex={'none'} gap={2} horizontal>
67
67
  <LangButton />
68
68
  <ThemeButton />
69
69
  </Flexbox>
@@ -1,6 +1,5 @@
1
1
  import { ActionIcon, Icon } from '@lobehub/ui';
2
2
  import { Popover, type PopoverProps } from 'antd';
3
- import { useTheme } from 'antd-style';
4
3
  import { Monitor, Moon, Sun } from 'lucide-react';
5
4
  import { memo, useMemo } from 'react';
6
5
  import { useTranslation } from 'react-i18next';
@@ -16,7 +15,6 @@ const themeIcons = {
16
15
  };
17
16
 
18
17
  const ThemeButton = memo<{ placement?: PopoverProps['placement'] }>(({ placement = 'right' }) => {
19
- const theme = useTheme();
20
18
  const [themeMode, switchThemeMode] = useGlobalStore((s) => [
21
19
  systemStatusSelectors.themeMode(s),
22
20
  s.switchThemeMode,
@@ -60,11 +58,7 @@ const ThemeButton = memo<{ placement?: PopoverProps['placement'] }>(({ placement
60
58
  }}
61
59
  trigger={['click', 'hover']}
62
60
  >
63
- <ActionIcon
64
- icon={themeIcons[themeMode]}
65
- size={{ blockSize: 32, size: 16 }}
66
- style={{ border: `1px solid ${theme.colorFillSecondary}` }}
67
- />
61
+ <ActionIcon icon={themeIcons[themeMode]} size={{ blockSize: 32, size: 16 }} />
68
62
  </Popover>
69
63
  );
70
64
  });
package/src/middleware.ts CHANGED
@@ -134,12 +134,23 @@ const defaultMiddleware = (request: NextRequest) => {
134
134
  return NextResponse.rewrite(url, { status: 200 });
135
135
  };
136
136
 
137
+ const isProtectedRoute = createRouteMatcher([
138
+ '/settings(.*)',
139
+ '/files(.*)',
140
+ '/onboard(.*)',
141
+ '/oauth(.*)',
142
+ // ↓ cloud ↓
143
+ ]);
144
+
137
145
  // Initialize an Edge compatible NextAuth middleware
138
146
  const nextAuthMiddleware = NextAuthEdge.auth((req) => {
139
147
  logNextAuth('NextAuth middleware processing request: %s %s', req.method, req.url);
140
148
 
141
149
  const response = defaultMiddleware(req);
142
150
 
151
+ const isProtected = isProtectedRoute(req);
152
+ logNextAuth('Route protection status: %s, %s', req.url, isProtected ? 'protected' : 'public');
153
+
143
154
  // Just check if session exists
144
155
  const session = req.auth;
145
156
 
@@ -165,20 +176,20 @@ const nextAuthMiddleware = NextAuthEdge.auth((req) => {
165
176
  response.headers.set(OIDC_SESSION_HEADER, session.user.id);
166
177
  }
167
178
  } else {
168
- logNextAuth('Not logged in, no auth header set');
179
+ // If request a protected route, redirect to sign-in page
180
+ // ref: https://authjs.dev/getting-started/session-management/protecting
181
+ if (isProtected) {
182
+ logNextAuth('Request a protected route, redirecting to sign-in page');
183
+ const nextLoginUrl = new URL('/next-auth/signin', req.nextUrl.origin);
184
+ nextLoginUrl.searchParams.set('callbackUrl', req.nextUrl.pathname);
185
+ return Response.redirect(nextLoginUrl);
186
+ }
187
+ logNextAuth('Request a free route but not login, allow visit without auth header');
169
188
  }
170
189
 
171
190
  return response;
172
191
  });
173
192
 
174
- const isProtectedRoute = createRouteMatcher([
175
- '/settings(.*)',
176
- '/files(.*)',
177
- '/onboard(.*)',
178
- '/oauth(.*)',
179
- // ↓ cloud ↓
180
- ]);
181
-
182
193
  const clerkAuthMiddleware = clerkMiddleware(
183
194
  async (auth, req) => {
184
195
  logClerk('Clerk middleware processing request: %s %s', req.method, req.url);
@@ -8,6 +8,7 @@ import { ImportResultData } from '@/types/importer';
8
8
  import { importerRouter } from '../importer';
9
9
 
10
10
  const mockGetFileContent = vi.fn();
11
+ const mockDeleteFile = vi.fn();
11
12
  const mockImportData = vi.fn();
12
13
  const mockImportPgData = vi.fn();
13
14
 
@@ -21,6 +22,7 @@ vi.mock('@/database/repositories/dataImporter', () => ({
21
22
  vi.mock('@/server/services/file', () => ({
22
23
  FileService: vi.fn().mockImplementation(() => ({
23
24
  getFileContent: mockGetFileContent,
25
+ deleteFile: mockDeleteFile,
24
26
  })),
25
27
  }));
26
28
 
@@ -74,6 +76,7 @@ describe('importerRouter', () => {
74
76
  expect(result).toEqual(mockImportResult);
75
77
  expect(mockGetFileContent).toHaveBeenCalledWith('test.json');
76
78
  expect(mockImportData).toHaveBeenCalledWith(JSON.parse(mockFileContent));
79
+ expect(mockDeleteFile).toHaveBeenCalledWith('test.json');
77
80
  });
78
81
 
79
82
  it('should handle PG data import', async () => {
@@ -39,11 +39,19 @@ export const importerRouter = router({
39
39
  });
40
40
  }
41
41
 
42
+ let result: ImportResultData;
42
43
  if ('schemaHash' in data) {
43
- return ctx.dataImporterService.importPgData(data as unknown as ImportPgDataStructure);
44
+ result = await ctx.dataImporterService.importPgData(
45
+ data as unknown as ImportPgDataStructure,
46
+ );
47
+ } else {
48
+ result = await ctx.dataImporterService.importData(data);
44
49
  }
45
50
 
46
- return ctx.dataImporterService.importData(data);
51
+ // clean file after upload
52
+ await ctx.fileService.deleteFile(input.pathname);
53
+
54
+ return result;
47
55
  }),
48
56
 
49
57
  importByPost: importProcedure
@@ -69,7 +69,7 @@ describe('UploadService', () => {
69
69
  }
70
70
  });
71
71
 
72
- const result = await uploadService.uploadWithProgress(mockFile, { onProgress });
72
+ const result = await uploadService.uploadToServerS3(mockFile, { onProgress });
73
73
 
74
74
  expect(result).toEqual({
75
75
  date: '1',
@@ -91,9 +91,7 @@ describe('UploadService', () => {
91
91
  }
92
92
  });
93
93
 
94
- await expect(uploadService.uploadWithProgress(mockFile, {})).rejects.toBe(
95
- UPLOAD_NETWORK_ERROR,
96
- );
94
+ await expect(uploadService.uploadToServerS3(mockFile, {})).rejects.toBe(UPLOAD_NETWORK_ERROR);
97
95
  });
98
96
 
99
97
  it('should handle upload error', async () => {
@@ -109,7 +107,7 @@ describe('UploadService', () => {
109
107
  }
110
108
  });
111
109
 
112
- await expect(uploadService.uploadWithProgress(mockFile, {})).rejects.toBe('Bad Request');
110
+ await expect(uploadService.uploadToServerS3(mockFile, {})).rejects.toBe('Bad Request');
113
111
  });
114
112
  });
115
113
 
@@ -125,7 +123,7 @@ describe('UploadService', () => {
125
123
 
126
124
  (clientS3Storage.putObject as any).mockResolvedValue(undefined);
127
125
 
128
- const result = await uploadService.uploadToClientS3(hash, mockFile);
126
+ const result = await uploadService['uploadToClientS3'](hash, mockFile);
129
127
 
130
128
  expect(clientS3Storage.putObject).toHaveBeenCalledWith(hash, mockFile);
131
129
  expect(result).toEqual(expectedResult);
@@ -1,6 +1,7 @@
1
1
  import { DefaultErrorShape } from '@trpc/server/unstable-core-do-not-import';
2
2
 
3
- import { edgeClient, lambdaClient } from '@/libs/trpc/client';
3
+ import { lambdaClient } from '@/libs/trpc/client';
4
+ import { uploadService } from '@/services/upload';
4
5
  import { useUserStore } from '@/store/user';
5
6
  import { ImportPgDataStructure } from '@/types/export';
6
7
  import { ImportStage, OnImportCallbacks } from '@/types/importer';
@@ -48,30 +49,7 @@ export class ServerService implements IImportService {
48
49
  return;
49
50
  }
50
51
 
51
- // if the data is too large, upload it to S3 and upload by file
52
- const filename = `${uuid()}.json`;
53
-
54
- const pathname = `import_config/${filename}`;
55
-
56
- const url = await edgeClient.upload.createS3PreSignedUrl.mutate({ pathname });
57
-
58
- try {
59
- callbacks?.onStageChange?.(ImportStage.Uploading);
60
- await this.uploadWithProgress(url, data, callbacks?.onFileUploading);
61
- } catch {
62
- throw new Error('Upload Error');
63
- }
64
-
65
- callbacks?.onStageChange?.(ImportStage.Importing);
66
- const time = Date.now();
67
- try {
68
- const result = await lambdaClient.importer.importByFile.mutate({ pathname });
69
- const duration = Date.now() - time;
70
- callbacks?.onStageChange?.(ImportStage.Success);
71
- callbacks?.onSuccess?.(result.results, duration);
72
- } catch (e) {
73
- handleError(e);
74
- }
52
+ await this.uploadData(data, { callbacks, handleError });
75
53
  };
76
54
 
77
55
  importPgData: IImportService['importPgData'] = async (
@@ -115,16 +93,28 @@ export class ServerService implements IImportService {
115
93
  return;
116
94
  }
117
95
 
96
+ await this.uploadData(data, { callbacks, handleError });
97
+ };
98
+
99
+ private uploadData = async (
100
+ data: object,
101
+ { callbacks, handleError }: { callbacks?: OnImportCallbacks; handleError: (e: unknown) => any },
102
+ ) => {
118
103
  // if the data is too large, upload it to S3 and upload by file
119
104
  const filename = `${uuid()}.json`;
120
105
 
121
- const pathname = `import_config/${filename}`;
122
-
123
- const url = await edgeClient.upload.createS3PreSignedUrl.mutate({ pathname });
124
-
106
+ let pathname;
125
107
  try {
126
108
  callbacks?.onStageChange?.(ImportStage.Uploading);
127
- await this.uploadWithProgress(url, data, callbacks?.onFileUploading);
109
+ const result = await uploadService.uploadDataToS3(data, {
110
+ filename,
111
+ onProgress: (status, state) => {
112
+ callbacks?.onFileUploading?.(state);
113
+ },
114
+ pathname: `import_config/${filename}`,
115
+ });
116
+ pathname = result.data.path;
117
+ console.log(pathname);
128
118
  } catch {
129
119
  throw new Error('Upload Error');
130
120
  }
@@ -140,45 +130,4 @@ export class ServerService implements IImportService {
140
130
  handleError(e);
141
131
  }
142
132
  };
143
-
144
- private uploadWithProgress = async (
145
- url: string,
146
- data: object,
147
- onProgress: OnImportCallbacks['onFileUploading'],
148
- ) => {
149
- const xhr = new XMLHttpRequest();
150
-
151
- let startTime = Date.now();
152
- xhr.upload.addEventListener('progress', (event) => {
153
- if (event.lengthComputable) {
154
- const progress = Number(((event.loaded / event.total) * 100).toFixed(1));
155
-
156
- const speedInByte = event.loaded / ((Date.now() - startTime) / 1000);
157
-
158
- onProgress?.({
159
- // if the progress is 100, it means the file is uploaded
160
- // but the server is still processing it
161
- // so make it as 99.5 and let users think it's still uploading
162
- progress: progress === 100 ? 99.5 : progress,
163
- restTime: (event.total - event.loaded) / speedInByte,
164
- speed: speedInByte / 1024,
165
- });
166
- }
167
- });
168
-
169
- xhr.open('PUT', url);
170
- xhr.setRequestHeader('Content-Type', 'application/json');
171
-
172
- return new Promise((resolve, reject) => {
173
- xhr.addEventListener('load', () => {
174
- if (xhr.status >= 200 && xhr.status < 300) {
175
- resolve(xhr.response);
176
- } else {
177
- reject(xhr.statusText);
178
- }
179
- });
180
- xhr.addEventListener('error', () => reject(xhr.statusText));
181
- xhr.send(JSON.stringify(data));
182
- });
183
- };
184
133
  }
@@ -40,7 +40,7 @@ class RAGEvalService {
40
40
  };
41
41
 
42
42
  importDatasetRecords = async (datasetId: number, file: File): Promise<void> => {
43
- const { path } = await uploadService.uploadWithProgress(file, { directory: 'ragEval' });
43
+ const { path } = await uploadService.uploadToServerS3(file, { directory: 'ragEval' });
44
44
 
45
45
  await lambdaClient.ragEval.importDatasetRecords.mutate({ datasetId, pathname: path });
46
46
  };
@@ -2,7 +2,7 @@ import dayjs from 'dayjs';
2
2
  import { sha256 } from 'js-sha256';
3
3
 
4
4
  import { fileEnv } from '@/config/file';
5
- import { isServerMode } from '@/const/version';
5
+ import { isDesktop, isServerMode } from '@/const/version';
6
6
  import { parseDataUri } from '@/libs/agent-runtime/utils/uriParser';
7
7
  import { edgeClient } from '@/libs/trpc/client';
8
8
  import { API_ENDPOINTS } from '@/services/_url';
@@ -16,7 +16,10 @@ export const UPLOAD_NETWORK_ERROR = 'NetWorkError';
16
16
  interface UploadFileToS3Options {
17
17
  directory?: string;
18
18
  filename?: string;
19
+ onNotSupported?: () => void;
19
20
  onProgress?: (status: FileUploadStatus, state: FileUploadState) => void;
21
+ pathname?: string;
22
+ skipCheckFileType?: boolean;
20
23
  }
21
24
 
22
25
  class UploadService {
@@ -25,20 +28,43 @@ class UploadService {
25
28
  */
26
29
  uploadFileToS3 = async (
27
30
  file: File,
28
- options: UploadFileToS3Options = {},
29
- ): Promise<FileMetadata> => {
30
- const { directory, onProgress } = options;
31
+ { onProgress, directory, skipCheckFileType, onNotSupported, pathname }: UploadFileToS3Options,
32
+ ): Promise<{ data: FileMetadata; success: boolean }> => {
33
+ const { getElectronStoreState } = await import('@/store/electron');
34
+ const { electronSyncSelectors } = await import('@/store/electron/selectors');
35
+ // only if not enable sync
36
+ const state = getElectronStoreState();
37
+ const isSyncActive = electronSyncSelectors.isSyncActive(state);
38
+
39
+ // 桌面端上传逻辑(并且没开启 sync 同步)
40
+ if (isDesktop && !isSyncActive) {
41
+ const data = await this.uploadToDesktopS3(file);
42
+ return { data, success: true };
43
+ }
31
44
 
45
+ // 服务端上传逻辑
32
46
  if (isServerMode) {
33
- return this.uploadWithProgress(file, { directory, onProgress });
34
- } else {
35
- const fileArrayBuffer = await file.arrayBuffer();
47
+ // if is server mode, upload to server s3,
36
48
 
37
- // 1. check file hash
38
- const hash = sha256(fileArrayBuffer);
49
+ const data = await this.uploadToServerS3(file, { directory, onProgress, pathname });
50
+ return { data, success: true };
51
+ }
39
52
 
40
- return this.uploadToClientS3(hash, file);
53
+ // upload to client s3
54
+ // 客户端上传逻辑
55
+ if (!skipCheckFileType && !file.type.startsWith('image')) {
56
+ onNotSupported?.();
57
+ return { data: undefined as unknown as FileMetadata, success: false };
41
58
  }
59
+
60
+ const fileArrayBuffer = await file.arrayBuffer();
61
+
62
+ // 1. check file hash
63
+ const hash = sha256(fileArrayBuffer);
64
+ // Upload to the indexeddb in the browser
65
+ const data = await this.uploadToClientS3(hash, file);
66
+
67
+ return { data, success: true };
42
68
  };
43
69
 
44
70
  uploadBase64ToS3 = async (
@@ -79,7 +105,7 @@ class UploadService {
79
105
  const file = new File([blob], fileName, { type: mimeType });
80
106
 
81
107
  // 使用统一的上传方法
82
- const metadata = await this.uploadFileToS3(file, options);
108
+ const { data: metadata } = await this.uploadFileToS3(file, options);
83
109
  const hash = sha256(await file.arrayBuffer());
84
110
 
85
111
  return {
@@ -90,19 +116,27 @@ class UploadService {
90
116
  };
91
117
  };
92
118
 
93
- uploadWithProgress = async (
119
+ uploadDataToS3 = async (data: object, options: UploadFileToS3Options = {}) => {
120
+ const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
121
+ const file = new File([blob], options.filename || 'data.json', { type: 'application/json' });
122
+ return await this.uploadFileToS3(file, options);
123
+ };
124
+
125
+ uploadToServerS3 = async (
94
126
  file: File,
95
127
  {
96
128
  onProgress,
97
129
  directory,
130
+ pathname,
98
131
  }: {
99
132
  directory?: string;
100
133
  onProgress?: (status: FileUploadStatus, state: FileUploadState) => void;
134
+ pathname?: string;
101
135
  },
102
136
  ): Promise<FileMetadata> => {
103
137
  const xhr = new XMLHttpRequest();
104
138
 
105
- const { preSignUrl, ...result } = await this.getSignedUploadUrl(file, directory);
139
+ const { preSignUrl, ...result } = await this.getSignedUploadUrl(file, { directory, pathname });
106
140
  let startTime = Date.now();
107
141
  xhr.upload.addEventListener('progress', (event) => {
108
142
  if (event.lengthComputable) {
@@ -148,7 +182,7 @@ class UploadService {
148
182
  return result;
149
183
  };
150
184
 
151
- uploadToDesktop = async (file: File) => {
185
+ private uploadToDesktopS3 = async (file: File) => {
152
186
  const fileArrayBuffer = await file.arrayBuffer();
153
187
  const hash = sha256(fileArrayBuffer);
154
188
 
@@ -157,7 +191,7 @@ class UploadService {
157
191
  return metadata;
158
192
  };
159
193
 
160
- uploadToClientS3 = async (hash: string, file: File): Promise<FileMetadata> => {
194
+ private uploadToClientS3 = async (hash: string, file: File): Promise<FileMetadata> => {
161
195
  await clientS3Storage.putObject(hash, file);
162
196
 
163
197
  return {
@@ -183,7 +217,7 @@ class UploadService {
183
217
 
184
218
  private getSignedUploadUrl = async (
185
219
  file: File,
186
- directory?: string,
220
+ options: { directory?: string; pathname?: string } = {},
187
221
  ): Promise<
188
222
  FileMetadata & {
189
223
  preSignUrl: string;
@@ -193,8 +227,8 @@ class UploadService {
193
227
 
194
228
  // 精确到以 h 为单位的 path
195
229
  const date = (Date.now() / 1000 / 60 / 60).toFixed(0);
196
- const dirname = `${directory || fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/${date}`;
197
- const pathname = `${dirname}/${filename}`;
230
+ const dirname = `${options.directory || fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/${date}`;
231
+ const pathname = options.pathname ?? `${dirname}/${filename}`;
198
232
 
199
233
  const preSignUrl = await edgeClient.upload.createS3PreSignedUrl.mutate({ pathname });
200
234
 
@@ -677,7 +677,15 @@ export const generateAIChat: StateCreator<
677
677
  // if there is no duration, it means the end of reasoning
678
678
  if (!duration) {
679
679
  duration = Date.now() - thinkingStartAt;
680
- internal_toggleChatReasoning(false, messageId, n('generateMessage(end)') as string);
680
+
681
+ const isInChatReasoning = chatSelectors.isMessageInChatReasoning(messageId)(get());
682
+ if (isInChatReasoning) {
683
+ internal_toggleChatReasoning(
684
+ false,
685
+ messageId,
686
+ n('toggleChatReasoning/false') as string,
687
+ );
688
+ }
681
689
  }
682
690
 
683
691
  internal_dispatchMessage({
@@ -695,7 +703,11 @@ export const generateAIChat: StateCreator<
695
703
  // if there is no thinkingStartAt, it means the start of reasoning
696
704
  if (!thinkingStartAt) {
697
705
  thinkingStartAt = Date.now();
698
- internal_toggleChatReasoning(true, messageId, n('generateMessage(end)') as string);
706
+ internal_toggleChatReasoning(
707
+ true,
708
+ messageId,
709
+ n('toggleChatReasoning/true') as string,
710
+ );
699
711
  }
700
712
 
701
713
  thinking += chunk.text;
@@ -41,6 +41,7 @@ describe('chatToolSlice - dalle', () => {
41
41
  vi.spyOn(uploadService, 'getImageFileByUrlWithCORS').mockResolvedValue(
42
42
  new File(['1'], 'file.png', { type: 'image/png' }),
43
43
  );
44
+ // @ts-ignore
44
45
  vi.spyOn(uploadService, 'uploadToClientS3').mockResolvedValue({} as any);
45
46
  vi.spyOn(ClientService.prototype, 'createFile').mockResolvedValue({
46
47
  id: mockId,
@@ -56,6 +57,7 @@ describe('chatToolSlice - dalle', () => {
56
57
  });
57
58
  // For each prompt, loading is toggled on and then off
58
59
  expect(imageGenerationService.generateImage).toHaveBeenCalledTimes(prompts.length);
60
+ // @ts-ignore
59
61
  expect(uploadService.uploadToClientS3).toHaveBeenCalledTimes(prompts.length);
60
62
  expect(result.current.toggleDallEImageLoading).toHaveBeenCalledTimes(prompts.length * 2);
61
63
  });
@@ -157,6 +157,9 @@ const isMessageLoading = (id: string) => (s: ChatStoreState) => s.messageLoading
157
157
  const isMessageGenerating = (id: string) => (s: ChatStoreState) => s.chatLoadingIds.includes(id);
158
158
  const isMessageInRAGFlow = (id: string) => (s: ChatStoreState) =>
159
159
  s.messageRAGLoadingIds.includes(id);
160
+ const isMessageInChatReasoning = (id: string) => (s: ChatStoreState) =>
161
+ s.reasoningLoadingIds.includes(id);
162
+
160
163
  const isPluginApiInvoking = (id: string) => (s: ChatStoreState) =>
161
164
  s.pluginApiLoadingIds.includes(id);
162
165
 
@@ -170,6 +173,7 @@ const isToolCallStreaming = (id: string, index: number) => (s: ChatStoreState) =
170
173
 
171
174
  const isAIGenerating = (s: ChatStoreState) =>
172
175
  s.chatLoadingIds.some((id) => mainDisplayChatIDs(s).includes(id));
176
+
173
177
  const isInRAGFlow = (s: ChatStoreState) =>
174
178
  s.messageRAGLoadingIds.some((id) => mainDisplayChatIDs(s).includes(id));
175
179
 
@@ -208,6 +212,7 @@ export const chatSelectors = {
208
212
  isHasMessageLoading,
209
213
  isMessageEditing,
210
214
  isMessageGenerating,
215
+ isMessageInChatReasoning,
211
216
  isMessageInRAGFlow,
212
217
  isMessageLoading,
213
218
  isPluginApiInvoking,
@@ -4,11 +4,8 @@ import { StateCreator } from 'zustand/vanilla';
4
4
 
5
5
  import { message } from '@/components/AntdStaticMethods';
6
6
  import { LOBE_CHAT_CLOUD } from '@/const/branding';
7
- import { isDesktop, isServerMode } from '@/const/version';
8
7
  import { fileService } from '@/services/file';
9
8
  import { uploadService } from '@/services/upload';
10
- import { getElectronStoreState } from '@/store/electron';
11
- import { electronSyncSelectors } from '@/store/electron/selectors';
12
9
  import { FileMetadata, UploadFileItem } from '@/types/files';
13
10
 
14
11
  import { FileStore } from '../../store';
@@ -96,25 +93,8 @@ export const createFileUploadSlice: StateCreator<
96
93
  }
97
94
  // 2. if file don't exist, need upload files
98
95
  else {
99
- // only if not enable sync
100
- const state = getElectronStoreState();
101
- const isSyncActive = electronSyncSelectors.isSyncActive(state);
102
-
103
- if (isDesktop && !isSyncActive) {
104
- metadata = await uploadService.uploadToDesktop(file);
105
- } else if (isServerMode) {
106
- // if is server mode, upload to server s3, or upload to client s3
107
- metadata = await uploadService.uploadWithProgress(file, {
108
- onProgress: (status, upload) => {
109
- onStatusUpdate?.({
110
- id: file.name,
111
- type: 'updateFile',
112
- value: { status: status === 'success' ? 'processing' : status, uploadState: upload },
113
- });
114
- },
115
- });
116
- } else {
117
- if (!skipCheckFileType && !file.type.startsWith('image')) {
96
+ const { data, success } = await uploadService.uploadFileToS3(file, {
97
+ onNotSupported: () => {
118
98
  onStatusUpdate?.({ id: file.name, type: 'removeFile' });
119
99
  message.info({
120
100
  content: t('upload.fileOnlySupportInServerMode', {
@@ -124,12 +104,19 @@ export const createFileUploadSlice: StateCreator<
124
104
  }),
125
105
  duration: 5,
126
106
  });
127
- return;
128
- }
107
+ },
108
+ onProgress: (status, upload) => {
109
+ onStatusUpdate?.({
110
+ id: file.name,
111
+ type: 'updateFile',
112
+ value: { status: status === 'success' ? 'processing' : status, uploadState: upload },
113
+ });
114
+ },
115
+ skipCheckFileType,
116
+ });
117
+ if (!success) return;
129
118
 
130
- // Upload to the indexeddb in the browser
131
- metadata = await uploadService.uploadToClientS3(hash, file);
132
- }
119
+ metadata = data;
133
120
  }
134
121
 
135
122
  // 3. use more powerful file type detector to get file type
@@ -41,8 +41,7 @@ describe('fetchSSE', () => {
41
41
  smoothing: false,
42
42
  });
43
43
 
44
- expect(mockOnMessageHandle).toHaveBeenNthCalledWith(1, { text: 'Hello', type: 'text' });
45
- expect(mockOnMessageHandle).toHaveBeenNthCalledWith(2, { text: ' World', type: 'text' });
44
+ expect(mockOnMessageHandle).toHaveBeenNthCalledWith(1, { text: 'Hello World', type: 'text' });
46
45
  expect(mockOnFinish).toHaveBeenCalledWith('Hello World', {
47
46
  observationId: null,
48
47
  toolCalls: undefined,
@@ -123,7 +122,7 @@ describe('fetchSSE', () => {
123
122
  });
124
123
  });
125
124
 
126
- it('should handle text event with smoothing correctly', async () => {
125
+ it.skip('should handle text event with smoothing correctly', async () => {
127
126
  const mockOnMessageHandle = vi.fn();
128
127
  const mockOnFinish = vi.fn();
129
128
 
@@ -178,9 +177,9 @@ describe('fetchSSE', () => {
178
177
  async (url: string, options: FetchEventSourceInit) => {
179
178
  options.onopen!({ clone: () => ({ ok: true, headers: new Headers() }) } as any);
180
179
  options.onmessage!({ event: 'reasoning', data: JSON.stringify('Hello') } as any);
181
- await sleep(100);
180
+ await sleep(400);
182
181
  options.onmessage!({ event: 'reasoning', data: JSON.stringify(' World') } as any);
183
- await sleep(100);
182
+ await sleep(400);
184
183
  options.onmessage!({ event: 'text', data: JSON.stringify('hi') } as any);
185
184
  },
186
185
  );
@@ -321,19 +320,7 @@ describe('fetchSSE', () => {
321
320
  smoothing: true,
322
321
  });
323
322
 
324
- const expectedMessages = [
325
- { text: 'H', type: 'text' },
326
- { text: 'e', type: 'text' },
327
- { text: 'l', type: 'text' },
328
- { text: 'l', type: 'text' },
329
- { text: 'o', type: 'text' },
330
- { text: ' ', type: 'text' },
331
- { text: 'W', type: 'text' },
332
- { text: 'o', type: 'text' },
333
- { text: 'r', type: 'text' },
334
- { text: 'l', type: 'text' },
335
- { text: 'd', type: 'text' },
336
- ];
323
+ const expectedMessages = [{ text: 'Hello World', type: 'text' }];
337
324
 
338
325
  expectedMessages.forEach((message, index) => {
339
326
  expect(mockOnMessageHandle).toHaveBeenNthCalledWith(index + 1, message);
@@ -315,11 +315,27 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
315
315
 
316
316
  const { smoothing } = options;
317
317
 
318
- const textSmoothing = typeof smoothing === 'boolean' ? smoothing : (smoothing?.text ?? true);
318
+ const textSmoothing = false;
319
+ // TODO: 看下后面就是完全移除 smoothing 还是怎么说
320
+ // const textSmoothing = typeof smoothing === 'boolean' ? smoothing : (smoothing?.text ?? true);
319
321
  const toolsCallingSmoothing =
320
322
  typeof smoothing === 'boolean' ? smoothing : (smoothing?.toolsCalling ?? true);
323
+
321
324
  const smoothingSpeed = isObject(smoothing) ? smoothing.speed : undefined;
322
325
 
326
+ // 添加文本buffer和计时器相关变量
327
+ let textBuffer = '';
328
+ // eslint-disable-next-line no-undef
329
+ let bufferTimer: NodeJS.Timeout | null = null;
330
+ const BUFFER_INTERVAL = 300; // 300ms
331
+
332
+ const flushTextBuffer = () => {
333
+ if (textBuffer) {
334
+ options.onMessageHandle?.({ text: textBuffer, type: 'text' });
335
+ textBuffer = '';
336
+ }
337
+ };
338
+
323
339
  let output = '';
324
340
  const textController = createSmoothMessage({
325
341
  onTextUpdate: (delta, text) => {
@@ -340,6 +356,18 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
340
356
  startSpeed: smoothingSpeed,
341
357
  });
342
358
 
359
+ let thinkingBuffer = '';
360
+ // eslint-disable-next-line no-undef
361
+ let thinkingBufferTimer: NodeJS.Timeout | null = null;
362
+
363
+ // 创建一个函数来处理buffer的刷新
364
+ const flushThinkingBuffer = () => {
365
+ if (thinkingBuffer) {
366
+ options.onMessageHandle?.({ text: thinkingBuffer, type: 'reasoning' });
367
+ thinkingBuffer = '';
368
+ }
369
+ };
370
+
343
371
  const toolCallsController = createSmoothToolCalls({
344
372
  onToolCallsUpdate: (toolCalls, isAnimationActives) => {
345
373
  options.onMessageHandle?.({ isAnimationActives, tool_calls: toolCalls, type: 'tool_calls' });
@@ -430,7 +458,17 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
430
458
  if (!textController.isAnimationActive) textController.startAnimation();
431
459
  } else {
432
460
  output += data;
433
- options.onMessageHandle?.({ text: data, type: 'text' });
461
+
462
+ // 使用buffer机制
463
+ textBuffer += data;
464
+
465
+ // 如果还没有设置计时器,创建一个
466
+ if (!bufferTimer) {
467
+ bufferTimer = setTimeout(() => {
468
+ flushTextBuffer();
469
+ bufferTimer = null;
470
+ }, BUFFER_INTERVAL);
471
+ }
434
472
  }
435
473
 
436
474
  break;
@@ -466,7 +504,17 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
466
504
  if (!thinkingController.isAnimationActive) thinkingController.startAnimation();
467
505
  } else {
468
506
  thinking += data;
469
- options.onMessageHandle?.({ text: data, type: 'reasoning' });
507
+
508
+ // 使用buffer机制
509
+ thinkingBuffer += data;
510
+
511
+ // 如果还没有设置计时器,创建一个
512
+ if (!thinkingBufferTimer) {
513
+ thinkingBufferTimer = setTimeout(() => {
514
+ flushThinkingBuffer();
515
+ thinkingBufferTimer = null;
516
+ }, BUFFER_INTERVAL);
517
+ }
470
518
  }
471
519
 
472
520
  break;
@@ -509,6 +557,17 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
509
557
  textController.stopAnimation();
510
558
  toolCallsController.stopAnimations();
511
559
 
560
+ // 确保所有缓冲区数据都被处理
561
+ if (bufferTimer) {
562
+ clearTimeout(bufferTimer);
563
+ flushTextBuffer();
564
+ }
565
+
566
+ if (thinkingBufferTimer) {
567
+ clearTimeout(thinkingBufferTimer);
568
+ flushThinkingBuffer();
569
+ }
570
+
512
571
  if (response.ok) {
513
572
  // if there is no onMessageHandler, we should call onHandleMessage first
514
573
  if (!triggerOnMessageHandler) {