@lobehub/chat 1.85.8 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
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
+
5
38
  ### [Version 1.85.8](https://github.com/lobehub/lobe-chat/compare/v1.85.7...v1.85.8)
6
39
 
7
40
  <sup>Released on **2025-05-11**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,16 @@
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
+ },
2
14
  {
3
15
  "children": {
4
16
  "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.8",
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",
@@ -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]);
@@ -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);
@@ -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;
@@ -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,
@@ -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) {