@lobehub/chat 1.69.4 → 1.69.6

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 (65) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/changelog/v1.json +18 -0
  3. package/docker-compose/setup.sh +92 -1
  4. package/docs/self-hosting/advanced/auth/clerk.mdx +1 -0
  5. package/locales/ar/components.json +2 -0
  6. package/locales/ar/models.json +3 -0
  7. package/locales/bg-BG/components.json +2 -0
  8. package/locales/bg-BG/models.json +3 -0
  9. package/locales/de-DE/components.json +2 -0
  10. package/locales/de-DE/models.json +3 -0
  11. package/locales/en-US/components.json +2 -0
  12. package/locales/en-US/models.json +3 -0
  13. package/locales/es-ES/components.json +2 -0
  14. package/locales/es-ES/models.json +3 -0
  15. package/locales/fa-IR/components.json +2 -0
  16. package/locales/fa-IR/models.json +3 -0
  17. package/locales/fr-FR/components.json +2 -0
  18. package/locales/fr-FR/models.json +3 -0
  19. package/locales/it-IT/components.json +2 -0
  20. package/locales/it-IT/models.json +3 -0
  21. package/locales/ja-JP/components.json +2 -0
  22. package/locales/ja-JP/models.json +3 -0
  23. package/locales/ko-KR/components.json +2 -0
  24. package/locales/ko-KR/models.json +3 -0
  25. package/locales/nl-NL/components.json +2 -0
  26. package/locales/nl-NL/models.json +3 -0
  27. package/locales/pl-PL/components.json +2 -0
  28. package/locales/pl-PL/models.json +3 -0
  29. package/locales/pt-BR/components.json +2 -0
  30. package/locales/pt-BR/models.json +3 -0
  31. package/locales/ru-RU/components.json +2 -0
  32. package/locales/ru-RU/models.json +3 -0
  33. package/locales/tr-TR/components.json +2 -0
  34. package/locales/tr-TR/models.json +3 -0
  35. package/locales/vi-VN/components.json +2 -0
  36. package/locales/vi-VN/models.json +3 -0
  37. package/locales/zh-CN/components.json +3 -1
  38. package/locales/zh-CN/models.json +3 -0
  39. package/locales/zh-TW/components.json +2 -0
  40. package/locales/zh-TW/models.json +3 -0
  41. package/package.json +4 -4
  42. package/packages/web-crawler/package.json +1 -1
  43. package/packages/web-crawler/src/crawImpl/__tests__/browserless.test.ts +94 -0
  44. package/packages/web-crawler/src/crawImpl/browserless.ts +1 -1
  45. package/packages/web-crawler/src/crawImpl/naive.ts +1 -1
  46. package/packages/web-crawler/src/utils/__snapshots__/htmlToMarkdown.test.ts.snap +2 -382
  47. package/packages/web-crawler/src/utils/htmlToMarkdown.ts +32 -2
  48. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Desktop/Footer/MessageFromUrl.tsx +31 -0
  49. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Desktop/Footer/index.tsx +45 -39
  50. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatList/WelcomeChatItem/InboxWelcome/AgentsSuggest.tsx +5 -1
  51. package/src/config/aiModels/openrouter.ts +26 -1
  52. package/src/config/modelProviders/openai.ts +3 -0
  53. package/src/database/client/db.ts +3 -3
  54. package/src/features/Conversation/components/MarkdownElements/LobeArtifact/Render/index.tsx +51 -52
  55. package/src/features/ModelSwitchPanel/index.tsx +37 -8
  56. package/src/libs/agent-runtime/anthropic/index.ts +5 -2
  57. package/src/libs/agent-runtime/openrouter/index.test.ts +33 -0
  58. package/src/libs/agent-runtime/openrouter/index.ts +11 -2
  59. package/src/libs/agent-runtime/openrouter/type.ts +19 -0
  60. package/src/locales/default/components.ts +3 -1
  61. package/src/services/__tests__/chat.test.ts +123 -0
  62. package/src/services/chat.ts +19 -19
  63. package/src/store/user/slices/modelList/action.ts +17 -16
  64. package/src/utils/fetch/__tests__/fetchSSE.test.ts +3 -2
  65. package/src/utils/fetch/fetchSSE.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  import { Button, Space } from 'antd';
2
2
  import { createStyles } from 'antd-style';
3
3
  import { rgba } from 'polished';
4
- import { memo, useEffect, useState } from 'react';
4
+ import { Suspense, memo, useEffect, useState } from 'react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { Flexbox } from 'react-layout-kit';
7
7
 
@@ -13,6 +13,7 @@ import { useChatStore } from '@/store/chat';
13
13
  import { chatSelectors } from '@/store/chat/selectors';
14
14
  import { isMacOS } from '@/utils/platform';
15
15
 
16
+ import MessageFromUrl from './MessageFromUrl';
16
17
  import SendMore from './SendMore';
17
18
  import ShortcutHint from './ShortcutHint';
18
19
 
@@ -67,49 +68,54 @@ const Footer = memo<FooterProps>(({ onExpandChange, expand }) => {
67
68
  }, [setIsMac]);
68
69
 
69
70
  return (
70
- <Flexbox
71
- align={'end'}
72
- className={styles.overrideAntdIcon}
73
- distribution={'space-between'}
74
- flex={'none'}
75
- gap={8}
76
- horizontal
77
- padding={'0 24px'}
78
- >
79
- <Flexbox align={'center'} gap={8} horizontal style={{ overflow: 'hidden' }}>
80
- {expand && <LocalFiles />}
81
- </Flexbox>
82
- <Flexbox align={'center'} flex={'none'} gap={8} horizontal>
83
- <ShortcutHint />
84
- <SaveTopic />
85
- <Flexbox style={{ minWidth: 92 }}>
86
- {isAIGenerating ? (
87
- <Button
88
- className={styles.loadingButton}
89
- icon={<StopLoadingIcon />}
90
- onClick={stopGenerateMessage}
91
- >
92
- {t('input.stop')}
93
- </Button>
94
- ) : (
95
- <Space.Compact>
71
+ <>
72
+ <Suspense fallback={null}>
73
+ <MessageFromUrl />
74
+ </Suspense>
75
+ <Flexbox
76
+ align={'end'}
77
+ className={styles.overrideAntdIcon}
78
+ distribution={'space-between'}
79
+ flex={'none'}
80
+ gap={8}
81
+ horizontal
82
+ padding={'0 24px'}
83
+ >
84
+ <Flexbox align={'center'} gap={8} horizontal style={{ overflow: 'hidden' }}>
85
+ {expand && <LocalFiles />}
86
+ </Flexbox>
87
+ <Flexbox align={'center'} flex={'none'} gap={8} horizontal>
88
+ <ShortcutHint />
89
+ <SaveTopic />
90
+ <Flexbox style={{ minWidth: 92 }}>
91
+ {isAIGenerating ? (
96
92
  <Button
97
- disabled={!canSend}
98
- loading={!canSend}
99
- onClick={() => {
100
- sendMessage();
101
- onExpandChange?.(false);
102
- }}
103
- type={'primary'}
93
+ className={styles.loadingButton}
94
+ icon={<StopLoadingIcon />}
95
+ onClick={stopGenerateMessage}
104
96
  >
105
- {t('input.send')}
97
+ {t('input.stop')}
106
98
  </Button>
107
- <SendMore disabled={!canSend} isMac={isMac} />
108
- </Space.Compact>
109
- )}
99
+ ) : (
100
+ <Space.Compact>
101
+ <Button
102
+ disabled={!canSend}
103
+ loading={!canSend}
104
+ onClick={() => {
105
+ sendMessage();
106
+ onExpandChange?.(false);
107
+ }}
108
+ type={'primary'}
109
+ >
110
+ {t('input.send')}
111
+ </Button>
112
+ <SendMore disabled={!canSend} isMac={isMac} />
113
+ </Space.Compact>
114
+ )}
115
+ </Flexbox>
110
116
  </Flexbox>
111
117
  </Flexbox>
112
- </Flexbox>
118
+ </>
113
119
  );
114
120
  });
115
121
 
@@ -105,7 +105,11 @@ const AgentsSuggest = memo<{ mobile?: boolean }>(({ mobile }) => {
105
105
  : assistantList
106
106
  .slice(sliceStart, sliceStart + agentLength)
107
107
  .map((item: DiscoverAssistantItem) => (
108
- <Link href={urlJoin('/discover/assistant/', item.identifier)} key={item.identifier}>
108
+ <Link
109
+ href={urlJoin('/discover/assistant/', item.identifier)}
110
+ key={item.identifier}
111
+ prefetch={false}
112
+ >
109
113
  <Flexbox className={styles.card} gap={8} horizontal>
110
114
  <Avatar avatar={item.meta.avatar} style={{ flex: 'none' }} />
111
115
  <Flexbox gap={mobile ? 2 : 8} style={{ overflow: 'hidden', width: '100%' }}>
@@ -137,6 +137,31 @@ const openrouterChatModels: AIChatModelCard[] = [
137
137
  releasedAt: '2024-06-20',
138
138
  type: 'chat',
139
139
  },
140
+ {
141
+ abilities: {
142
+ functionCall: true,
143
+ reasoning: true,
144
+ vision: true,
145
+ },
146
+ contextWindowTokens: 200_000,
147
+ description:
148
+ 'Claude 3.7 Sonnet 是 Anthropic 迄今为止最智能的模型,也是市场上首个混合推理模型。Claude 3.7 Sonnet 可以产生近乎即时的响应或延长的逐步思考,用户可以清晰地看到这些过程。Sonnet 特别擅长编程、数据科学、视觉处理、代理任务。',
149
+ displayName: 'Claude 3.7 Sonnet',
150
+ enabled: true,
151
+ id: 'anthropic/claude-3.7-sonnet',
152
+ maxOutput: 8192,
153
+ pricing: {
154
+ cachedInput: 0.3,
155
+ input: 3,
156
+ output: 15,
157
+ writeCacheInput: 3.75,
158
+ },
159
+ releasedAt: '2025-02-24',
160
+ settings: {
161
+ extendParams: ['enableReasoning', 'reasoningBudgetToken'],
162
+ },
163
+ type: 'chat',
164
+ },
140
165
  {
141
166
  abilities: {
142
167
  functionCall: true,
@@ -258,7 +283,7 @@ const openrouterChatModels: AIChatModelCard[] = [
258
283
  id: 'deepseek/deepseek-r1:free',
259
284
  releasedAt: '2025-01-20',
260
285
  type: 'chat',
261
- },
286
+ },
262
287
  {
263
288
  abilities: {
264
289
  vision: true,
@@ -301,6 +301,9 @@ const OpenAI: ModelProviderCard = {
301
301
  name: 'OpenAI',
302
302
  settings: {
303
303
  showModelFetcher: true,
304
+ smoothing: {
305
+ text: true,
306
+ },
304
307
  },
305
308
  url: 'https://openai.com',
306
309
  };
@@ -26,13 +26,13 @@ export class DatabaseManager {
26
26
 
27
27
  // CDN 配置
28
28
  private static WASM_CDN_URL =
29
- 'https://registry.npmmirror.com/@electric-sql/pglite/0.2.13/files/dist/postgres.wasm';
29
+ 'https://registry.npmmirror.com/@electric-sql/pglite/0.2.17/files/dist/postgres.wasm';
30
30
 
31
31
  private static FSBUNDLER_CDN_URL =
32
- 'https://registry.npmmirror.com/@electric-sql/pglite/0.2.13/files/dist/postgres.data';
32
+ 'https://registry.npmmirror.com/@electric-sql/pglite/0.2.17/files/dist/postgres.data';
33
33
 
34
34
  private static VECTOR_CDN_URL =
35
- 'https://registry.npmmirror.com/@electric-sql/pglite/0.2.13/files/dist/vector.tar.gz';
35
+ 'https://registry.npmmirror.com/@electric-sql/pglite/0.2.17/files/dist/vector.tar.gz';
36
36
 
37
37
  private constructor() {}
38
38
 
@@ -64,16 +64,14 @@ const Render = memo<ArtifactProps>(({ identifier, title, type, language, childre
64
64
 
65
65
  const inThread = useContext(InPortalThreadContext);
66
66
  const { message } = App.useApp();
67
- const [isGenerating, isArtifactTagClosed, currentArtifactMessageId, openArtifact, closeArtifact] =
68
- useChatStore((s) => {
69
- return [
70
- chatSelectors.isMessageGenerating(id)(s),
71
- chatPortalSelectors.isArtifactTagClosed(id)(s),
72
- chatPortalSelectors.artifactMessageId(s),
73
- s.openArtifact,
74
- s.closeArtifact,
75
- ];
76
- });
67
+ const [isGenerating, isArtifactTagClosed, openArtifact, closeArtifact] = useChatStore((s) => {
68
+ return [
69
+ chatSelectors.isMessageGenerating(id)(s),
70
+ chatPortalSelectors.isArtifactTagClosed(id)(s),
71
+ s.openArtifact,
72
+ s.closeArtifact,
73
+ ];
74
+ });
77
75
 
78
76
  const openArtifactUI = () => {
79
77
  openArtifact({ id, identifier, language, title, type });
@@ -86,52 +84,53 @@ const Render = memo<ArtifactProps>(({ identifier, title, type, language, childre
86
84
  }, [isGenerating, hasChildren, str, identifier, title, type, id, language]);
87
85
 
88
86
  return (
89
- <p>
90
- <Flexbox
91
- className={styles.container}
92
- gap={16}
93
- onClick={() => {
94
- if (currentArtifactMessageId === id) {
95
- closeArtifact();
96
- } else {
97
- if (inThread) {
98
- message.info(t('artifact.inThread'));
99
- return;
100
- }
101
- openArtifactUI();
87
+ <Flexbox
88
+ className={styles.container}
89
+ gap={16}
90
+ onClick={() => {
91
+ const currentArtifactMessageId = chatPortalSelectors.artifactMessageId(
92
+ useChatStore.getState(),
93
+ );
94
+ if (currentArtifactMessageId === id) {
95
+ closeArtifact();
96
+ } else {
97
+ if (inThread) {
98
+ message.info(t('artifact.inThread'));
99
+ return;
102
100
  }
103
- }}
104
- width={'100%'}
105
- >
106
- <Flexbox align={'center'} flex={1} horizontal>
107
- <Center className={styles.avatar} height={64} horizontal width={64}>
108
- <ArtifactIcon type={type} />
109
- </Center>
110
- <Flexbox gap={4} paddingBlock={8} paddingInline={12}>
111
- {!title && isGenerating ? (
112
- <Flexbox className={cx(dotLoading)} horizontal>
113
- {t('artifact.generating')}
101
+ openArtifactUI();
102
+ }
103
+ }}
104
+ width={'100%'}
105
+ >
106
+ <Flexbox align={'center'} flex={1} horizontal>
107
+ <Center className={styles.avatar} height={64} horizontal width={64}>
108
+ <ArtifactIcon type={type} />
109
+ </Center>
110
+ <Flexbox gap={4} paddingBlock={8} paddingInline={12}>
111
+ {!title && isGenerating ? (
112
+ <Flexbox className={cx(dotLoading)} horizontal>
113
+ {t('artifact.generating')}
114
+ </Flexbox>
115
+ ) : (
116
+ <Flexbox className={cx(styles.title)}>{title || t('artifact.unknownTitle')}</Flexbox>
117
+ )}
118
+ {hasChildren && (
119
+ <Flexbox className={styles.desc} horizontal>
120
+ {identifier} ·{' '}
121
+ <Flexbox gap={2} horizontal>
122
+ {!isArtifactTagClosed && (
123
+ <div>
124
+ <Icon icon={Loader2} spin />
125
+ </div>
126
+ )}
127
+ {str?.length}
114
128
  </Flexbox>
115
- ) : (
116
- <Flexbox className={cx(styles.title)}>{title || t('artifact.unknownTitle')}</Flexbox>
117
- )}
118
- {hasChildren && (
119
- <Flexbox className={styles.desc} horizontal>
120
- {identifier} ·{' '}
121
- <Flexbox gap={2} horizontal>
122
- {!isArtifactTagClosed && (
123
- <div>
124
- <Icon icon={Loader2} spin />
125
- </div>
126
- )}
127
- {str?.length}
128
- </Flexbox>
129
- </Flexbox>
130
- )}
131
- </Flexbox>
129
+ </Flexbox>
130
+ )}
132
131
  </Flexbox>
133
132
  </Flexbox>
134
- </p>
133
+ </Flexbox>
135
134
  );
136
135
  });
137
136
 
@@ -1,8 +1,9 @@
1
- import { Icon } from '@lobehub/ui';
1
+ import { ActionIcon, Icon } from '@lobehub/ui';
2
2
  import { Dropdown } from 'antd';
3
3
  import { createStyles } from 'antd-style';
4
4
  import type { ItemType } from 'antd/es/menu/interface';
5
- import { LucideArrowRight } from 'lucide-react';
5
+ import { LucideArrowRight, LucideBolt } from 'lucide-react';
6
+ import Link from 'next/link';
6
7
  import { useRouter } from 'next/navigation';
7
8
  import { PropsWithChildren, memo, useMemo } from 'react';
8
9
  import { useTranslation } from 'react-i18next';
@@ -86,17 +87,45 @@ const ModelSwitchPanel = memo<PropsWithChildren>(({ children }) => {
86
87
  return items;
87
88
  };
88
89
 
90
+ if (enabledList.length === 0)
91
+ return [
92
+ {
93
+ key: `no-provider`,
94
+ label: (
95
+ <Flexbox gap={8} horizontal style={{ color: theme.colorTextTertiary }}>
96
+ {t('ModelSwitchPanel.emptyProvider')}
97
+ <Icon icon={LucideArrowRight} />
98
+ </Flexbox>
99
+ ),
100
+ onClick: () => {
101
+ router.push(isDeprecatedEdition ? '/settings/llm' : `/settings/provider`);
102
+ },
103
+ },
104
+ ];
105
+
89
106
  // otherwise show with provider group
90
107
  return enabledList.map((provider) => ({
91
108
  children: getModelItems(provider),
92
109
  key: provider.id,
93
110
  label: (
94
- <ProviderItemRender
95
- logo={provider.logo}
96
- name={provider.name}
97
- provider={provider.id}
98
- source={provider.source}
99
- />
111
+ <Flexbox horizontal justify="space-between">
112
+ <ProviderItemRender
113
+ logo={provider.logo}
114
+ name={provider.name}
115
+ provider={provider.id}
116
+ source={provider.source}
117
+ />
118
+ <Link
119
+ href={isDeprecatedEdition ? '/settings/llm' : `/settings/provider/${provider.id}`}
120
+ prefetch={false}
121
+ >
122
+ <ActionIcon
123
+ icon={LucideBolt}
124
+ size={'small'}
125
+ title={t('ModelSwitchPanel.goToSettings')}
126
+ />
127
+ </Link>
128
+ </Flexbox>
100
129
  ),
101
130
  type: 'group',
102
131
  }));
@@ -102,7 +102,8 @@ export class LobeAnthropicAI implements LobeRuntimeAI {
102
102
 
103
103
  if (!!thinking) {
104
104
  const maxTokens =
105
- max_tokens ?? (thinking?.budget_tokens ? thinking?.budget_tokens + 4096 : 4096);
105
+ // claude 3.7 thinking has max output of 64000 tokens
106
+ max_tokens ?? (thinking?.budget_tokens ? thinking?.budget_tokens + 64_000 : 8192);
106
107
 
107
108
  // `temperature` may only be set to 1 when thinking is enabled.
108
109
  // `top_p` must be unset when thinking is enabled.
@@ -117,7 +118,9 @@ export class LobeAnthropicAI implements LobeRuntimeAI {
117
118
  }
118
119
 
119
120
  return {
120
- max_tokens: max_tokens ?? 4096,
121
+ // claude 3 series model hax max output token of 4096, 3.x series has 8192
122
+ // https://docs.anthropic.com/en/docs/about-claude/models/all-models#:~:text=200K-,Max%20output,-Normal%3A
123
+ max_tokens: max_tokens ?? (model.startsWith('claude-3-') ? 4096 : 8192),
121
124
  messages: postMessages,
122
125
  model,
123
126
  system: systemPrompts,
@@ -92,6 +92,39 @@ describe('LobeOpenRouterAI', () => {
92
92
  expect(result).toBeInstanceOf(Response);
93
93
  });
94
94
 
95
+ it('should add reasoning field when thinking is enabled', async () => {
96
+ // Arrange
97
+ const mockStream = new ReadableStream();
98
+ const mockResponse = Promise.resolve(mockStream);
99
+
100
+ (instance['client'].chat.completions.create as Mock).mockResolvedValue(mockResponse);
101
+
102
+ // Act
103
+ const result = await instance.chat({
104
+ messages: [{ content: 'Hello', role: 'user' }],
105
+ model: 'mistralai/mistral-7b-instruct:free',
106
+ temperature: 0.7,
107
+ thinking: {
108
+ type: 'enabled',
109
+ budget_tokens: 1500,
110
+ },
111
+ });
112
+
113
+ // Assert
114
+ expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
115
+ expect.objectContaining({
116
+ messages: [{ content: 'Hello', role: 'user' }],
117
+ model: 'mistralai/mistral-7b-instruct:free',
118
+ reasoning: {
119
+ max_tokens: 1500,
120
+ },
121
+ temperature: 0.7,
122
+ }),
123
+ { headers: { Accept: '*/*' } },
124
+ );
125
+ expect(result).toBeInstanceOf(Response);
126
+ });
127
+
95
128
  describe('Error', () => {
96
129
  it('should return OpenRouterBizError with an openai error response when OpenAI.APIError is thrown', async () => {
97
130
  // Arrange
@@ -2,7 +2,7 @@ import type { ChatModelCard } from '@/types/llm';
2
2
 
3
3
  import { ModelProvider } from '../types';
4
4
  import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory';
5
- import { OpenRouterModelCard, OpenRouterModelExtraInfo } from './type';
5
+ import { OpenRouterModelCard, OpenRouterModelExtraInfo, OpenRouterReasoning } from './type';
6
6
 
7
7
  const formatPrice = (price: string) => {
8
8
  if (price === '-1') return undefined;
@@ -13,10 +13,19 @@ export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({
13
13
  baseURL: 'https://openrouter.ai/api/v1',
14
14
  chatCompletion: {
15
15
  handlePayload: (payload) => {
16
+ const { thinking } = payload;
17
+
18
+ let reasoning: OpenRouterReasoning = {};
19
+ if (thinking?.type === 'enabled') {
20
+ reasoning = {
21
+ max_tokens: thinking.budget_tokens,
22
+ };
23
+ }
24
+
16
25
  return {
17
26
  ...payload,
18
- include_reasoning: true,
19
27
  model: payload.enabledSearch ? `${payload.model}:online` : payload.model,
28
+ reasoning,
20
29
  stream: payload.stream ?? true,
21
30
  } as any;
22
31
  },
@@ -37,3 +37,22 @@ export interface OpenRouterModelExtraInfo {
37
37
  endpoint?: OpenRouterModelEndpoint;
38
38
  slug: string;
39
39
  }
40
+
41
+ interface OpenRouterOpenAIReasoning {
42
+ effort: 'high' | 'medium' | 'low';
43
+ exclude?: boolean;
44
+ }
45
+
46
+ interface OpenRouterAnthropicReasoning {
47
+ exclude?: boolean;
48
+ max_tokens: number;
49
+ }
50
+
51
+ interface OpenRouterCommonReasoning {
52
+ exclude?: boolean;
53
+ }
54
+
55
+ export type OpenRouterReasoning =
56
+ | OpenRouterOpenAIReasoning
57
+ | OpenRouterAnthropicReasoning
58
+ | OpenRouterCommonReasoning;
@@ -87,7 +87,9 @@ export default {
87
87
  },
88
88
  ModelSwitchPanel: {
89
89
  emptyModel: '没有启用的模型,请前往设置开启',
90
- provider: '提供商',
90
+ emptyProvider: '没有启用的服务商,请前往设置开启',
91
+ goToSettings: '前往设置',
92
+ provider: '服务商',
91
93
  },
92
94
  OllamaSetupGuide: {
93
95
  cors: {
@@ -26,12 +26,16 @@ import {
26
26
  ModelProvider,
27
27
  } from '@/libs/agent-runtime';
28
28
  import { AgentRuntime } from '@/libs/agent-runtime';
29
+ import { agentChatConfigSelectors } from '@/store/agent/selectors';
30
+ import { aiModelSelectors } from '@/store/aiInfra';
29
31
  import { useToolStore } from '@/store/tool';
32
+ import { toolSelectors } from '@/store/tool/selectors';
30
33
  import { UserStore } from '@/store/user';
31
34
  import { useUserStore } from '@/store/user';
32
35
  import { modelConfigSelectors } from '@/store/user/selectors';
33
36
  import { UserSettingsState, initialSettingsState } from '@/store/user/slices/settings/initialState';
34
37
  import { DalleManifest } from '@/tools/dalle';
38
+ import { WebBrowsingManifest } from '@/tools/web-browsing';
35
39
  import { ChatMessage } from '@/types/message';
36
40
  import { ChatStreamPayload, type OpenAIChatMessage } from '@/types/openai/chat';
37
41
  import { LobeTool } from '@/types/tool';
@@ -480,6 +484,125 @@ describe('ChatService', () => {
480
484
  expect(calls![1]).toBeUndefined();
481
485
  });
482
486
  });
487
+
488
+ describe('search functionality', () => {
489
+ it('should add WebBrowsingManifest when search is enabled and not using model built-in search', async () => {
490
+ const getChatCompletionSpy = vi.spyOn(chatService, 'getChatCompletion');
491
+
492
+ const messages = [{ content: 'Search for something', role: 'user' }] as ChatMessage[];
493
+
494
+ // Mock agent store state with search enabled
495
+ vi.spyOn(agentChatConfigSelectors, 'currentChatConfig').mockReturnValueOnce({
496
+ searchMode: 'auto', // not 'off'
497
+ useModelBuiltinSearch: false,
498
+ } as any);
499
+
500
+ // Mock AI infra store state
501
+ vi.spyOn(aiModelSelectors, 'isModelHasBuiltinSearch').mockReturnValueOnce(() => false);
502
+ vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValueOnce(() => false);
503
+
504
+ // Mock tool selectors
505
+ vi.spyOn(toolSelectors, 'enabledSchema').mockReturnValueOnce(() => [
506
+ {
507
+ type: 'function',
508
+ function: {
509
+ name: WebBrowsingManifest.identifier + '____search',
510
+ description: 'Search the web',
511
+ },
512
+ },
513
+ ]);
514
+
515
+ await chatService.createAssistantMessage({ messages, plugins: [] });
516
+
517
+ // Verify tools were passed to getChatCompletion
518
+ expect(getChatCompletionSpy).toHaveBeenCalledWith(
519
+ expect.objectContaining({
520
+ tools: expect.arrayContaining([
521
+ expect.objectContaining({
522
+ function: expect.objectContaining({
523
+ name: expect.stringContaining(WebBrowsingManifest.identifier),
524
+ }),
525
+ }),
526
+ ]),
527
+ }),
528
+ undefined,
529
+ );
530
+ });
531
+
532
+ it('should enable built-in search when model supports it and useModelBuiltinSearch is true', async () => {
533
+ const getChatCompletionSpy = vi.spyOn(chatService, 'getChatCompletion');
534
+
535
+ const messages = [{ content: 'Search for something', role: 'user' }] as ChatMessage[];
536
+
537
+ // Mock agent store state with search enabled and useModelBuiltinSearch enabled
538
+ vi.spyOn(agentChatConfigSelectors, 'currentChatConfig').mockReturnValueOnce({
539
+ searchMode: 'auto', // not 'off'
540
+ useModelBuiltinSearch: true,
541
+ } as any);
542
+
543
+ // Mock AI infra store state - model has built-in search
544
+ vi.spyOn(aiModelSelectors, 'isModelHasBuiltinSearch').mockReturnValueOnce(() => true);
545
+ vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValueOnce(() => false);
546
+
547
+ // Mock tool selectors
548
+ vi.spyOn(toolSelectors, 'enabledSchema').mockReturnValueOnce(() => [
549
+ {
550
+ type: 'function',
551
+ function: {
552
+ name: WebBrowsingManifest.identifier + '____search',
553
+ description: 'Search the web',
554
+ },
555
+ },
556
+ ]);
557
+
558
+ await chatService.createAssistantMessage({ messages, plugins: [] });
559
+
560
+ // Verify enabledSearch was set to true
561
+ expect(getChatCompletionSpy).toHaveBeenCalledWith(
562
+ expect.objectContaining({
563
+ enabledSearch: true,
564
+ }),
565
+ undefined,
566
+ );
567
+ });
568
+
569
+ it('should not enable search when searchMode is off', async () => {
570
+ const getChatCompletionSpy = vi.spyOn(chatService, 'getChatCompletion');
571
+
572
+ const messages = [{ content: 'Search for something', role: 'user' }] as ChatMessage[];
573
+
574
+ // Mock agent store state with search disabled
575
+ vi.spyOn(agentChatConfigSelectors, 'currentChatConfig').mockReturnValueOnce({
576
+ searchMode: 'off',
577
+ useModelBuiltinSearch: true,
578
+ } as any);
579
+
580
+ // Mock AI infra store state
581
+ vi.spyOn(aiModelSelectors, 'isModelHasBuiltinSearch').mockReturnValueOnce(() => true);
582
+ vi.spyOn(aiModelSelectors, 'isModelHasExtendParams').mockReturnValueOnce(() => false);
583
+
584
+ // Mock tool selectors
585
+ vi.spyOn(toolSelectors, 'enabledSchema').mockReturnValueOnce(() => [
586
+ {
587
+ type: 'function',
588
+ function: {
589
+ name: WebBrowsingManifest.identifier + '____search',
590
+ description: 'Search the web',
591
+ },
592
+ },
593
+ ]);
594
+
595
+ await chatService.createAssistantMessage({ messages, plugins: [] });
596
+
597
+ // Verify enabledSearch was not set
598
+ expect(getChatCompletionSpy).toHaveBeenCalledWith(
599
+ expect.objectContaining({
600
+ enabledSearch: undefined,
601
+ }),
602
+ undefined,
603
+ );
604
+ });
605
+ });
483
606
  });
484
607
 
485
608
  describe('getChatCompletion', () => {