@lobehub/chat 1.124.1 → 1.124.3

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 (119) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -0
  3. package/package.json +1 -1
  4. package/packages/const/package.json +3 -1
  5. package/packages/const/src/analytics.ts +1 -1
  6. package/packages/const/src/desktop.ts +3 -2
  7. package/packages/const/src/discover.ts +3 -2
  8. package/packages/const/src/guide.ts +2 -2
  9. package/packages/const/src/hotkeys.ts +1 -1
  10. package/packages/const/src/index.ts +2 -0
  11. package/packages/const/src/settings/common.ts +1 -1
  12. package/packages/const/src/settings/genUserLLMConfig.test.ts +1 -2
  13. package/packages/const/src/settings/genUserLLMConfig.ts +1 -2
  14. package/packages/const/src/settings/hotkey.ts +3 -2
  15. package/packages/const/src/settings/index.ts +1 -3
  16. package/packages/const/src/settings/knowledge.ts +1 -1
  17. package/packages/const/src/settings/llm.ts +2 -4
  18. package/packages/const/src/settings/systemAgent.ts +1 -5
  19. package/packages/const/src/settings/tts.ts +1 -1
  20. package/packages/const/src/trace.ts +1 -1
  21. package/packages/const/src/url.ts +2 -2
  22. package/packages/const/src/user.ts +1 -2
  23. package/packages/database/src/client/db.test.ts +19 -13
  24. package/packages/electron-server-ipc/src/ipcClient.test.ts +783 -1
  25. package/packages/file-loaders/src/loadFile.test.ts +61 -0
  26. package/packages/file-loaders/src/utils/isTextReadableFile.test.ts +43 -0
  27. package/packages/file-loaders/src/utils/parser-utils.test.ts +155 -0
  28. package/packages/model-runtime/package.json +2 -1
  29. package/packages/model-runtime/src/ai21/index.test.ts +2 -2
  30. package/packages/model-runtime/src/ai360/index.test.ts +2 -2
  31. package/packages/model-runtime/src/akashchat/index.test.ts +19 -0
  32. package/packages/model-runtime/src/anthropic/index.test.ts +1 -2
  33. package/packages/model-runtime/src/baichuan/index.test.ts +1 -2
  34. package/packages/model-runtime/src/bedrock/index.test.ts +1 -2
  35. package/packages/model-runtime/src/bfl/createImage.test.ts +1 -2
  36. package/packages/model-runtime/src/bfl/index.test.ts +1 -2
  37. package/packages/model-runtime/src/cloudflare/index.test.ts +1 -2
  38. package/packages/model-runtime/src/cohere/index.test.ts +19 -0
  39. package/packages/model-runtime/src/deepseek/index.test.ts +2 -2
  40. package/packages/model-runtime/src/fireworksai/index.test.ts +2 -2
  41. package/packages/model-runtime/src/giteeai/index.test.ts +2 -2
  42. package/packages/model-runtime/src/github/index.test.ts +2 -2
  43. package/packages/model-runtime/src/google/createImage.test.ts +1 -2
  44. package/packages/model-runtime/src/google/index.test.ts +1 -1
  45. package/packages/model-runtime/src/groq/index.test.ts +2 -3
  46. package/packages/model-runtime/src/higress/index.ts +2 -3
  47. package/packages/model-runtime/src/huggingface/index.test.ts +40 -0
  48. package/packages/model-runtime/src/hunyuan/index.test.ts +2 -3
  49. package/packages/model-runtime/src/internlm/index.test.ts +2 -2
  50. package/packages/model-runtime/src/jina/index.test.ts +19 -0
  51. package/packages/model-runtime/src/lmstudio/index.test.ts +2 -2
  52. package/packages/model-runtime/src/minimax/index.test.ts +19 -0
  53. package/packages/model-runtime/src/mistral/index.test.ts +2 -3
  54. package/packages/model-runtime/src/modelscope/index.test.ts +19 -0
  55. package/packages/model-runtime/src/moonshot/index.test.ts +1 -2
  56. package/packages/model-runtime/src/nebius/index.test.ts +19 -0
  57. package/packages/model-runtime/src/novita/index.test.ts +3 -4
  58. package/packages/model-runtime/src/nvidia/index.test.ts +19 -0
  59. package/packages/model-runtime/src/openrouter/index.test.ts +2 -3
  60. package/packages/model-runtime/src/perplexity/index.test.ts +2 -3
  61. package/packages/model-runtime/src/ppio/index.test.ts +3 -4
  62. package/packages/model-runtime/src/qwen/index.test.ts +2 -2
  63. package/packages/model-runtime/src/sambanova/index.test.ts +19 -0
  64. package/packages/model-runtime/src/search1api/index.test.ts +19 -0
  65. package/packages/model-runtime/src/sensenova/index.test.ts +2 -2
  66. package/packages/model-runtime/src/spark/index.test.ts +2 -2
  67. package/packages/model-runtime/src/stepfun/index.test.ts +2 -2
  68. package/packages/model-runtime/src/taichu/index.test.ts +4 -5
  69. package/packages/model-runtime/src/tencentcloud/index.test.ts +1 -1
  70. package/packages/model-runtime/src/togetherai/index.test.ts +1 -2
  71. package/packages/model-runtime/src/upstage/index.test.ts +1 -2
  72. package/packages/model-runtime/src/utils/openaiCompatibleFactory/index.test.ts +9 -7
  73. package/packages/model-runtime/src/utils/streams/anthropic.ts +2 -2
  74. package/packages/model-runtime/src/utils/streams/openai/openai.ts +20 -13
  75. package/packages/model-runtime/src/utils/streams/openai/responsesStream.test.ts +1 -2
  76. package/packages/model-runtime/src/utils/streams/openai/responsesStream.ts +2 -2
  77. package/packages/model-runtime/src/utils/streams/protocol.ts +2 -2
  78. package/packages/model-runtime/src/wenxin/index.test.ts +2 -3
  79. package/packages/model-runtime/src/xai/index.test.ts +2 -2
  80. package/packages/model-runtime/src/zeroone/index.test.ts +1 -2
  81. package/packages/model-runtime/src/zhipu/index.test.ts +2 -3
  82. package/packages/model-runtime/vitest.config.mts +0 -7
  83. package/packages/types/src/discover/index.ts +0 -8
  84. package/packages/types/src/index.ts +4 -0
  85. package/packages/types/src/message/base.ts +1 -1
  86. package/packages/types/src/openai/chat.ts +2 -3
  87. package/packages/types/src/tool/index.ts +1 -0
  88. package/packages/types/src/tool/tool.ts +1 -1
  89. package/packages/types/src/user/settings/index.ts +1 -2
  90. package/packages/utils/package.json +2 -1
  91. package/packages/utils/src/_deprecated/parseModels.test.ts +1 -1
  92. package/packages/utils/src/_deprecated/parseModels.ts +1 -1
  93. package/packages/utils/src/client/topic.test.ts +1 -2
  94. package/packages/utils/src/client/topic.ts +1 -2
  95. package/packages/utils/src/electron/desktopRemoteRPCFetch.ts +1 -1
  96. package/packages/utils/src/fetch/fetchSSE.ts +7 -8
  97. package/packages/utils/src/fetch/parseError.ts +1 -3
  98. package/packages/utils/src/format.test.ts +1 -2
  99. package/packages/utils/src/index.ts +1 -0
  100. package/packages/utils/src/toolManifest.ts +1 -2
  101. package/packages/utils/src/trace.ts +1 -1
  102. package/packages/utils/vitest.config.mts +1 -2
  103. package/packages/web-crawler/src/__tests__/urlRules.test.ts +275 -0
  104. package/packages/web-crawler/src/crawImpl/__tests__/exa.test.ts +269 -0
  105. package/packages/web-crawler/src/crawImpl/__tests__/firecrawl.test.ts +284 -0
  106. package/packages/web-crawler/src/crawImpl/__tests__/naive.test.ts +234 -0
  107. package/packages/web-crawler/src/crawImpl/__tests__/tavily.test.ts +359 -0
  108. package/packages/web-crawler/src/utils/__tests__/errorType.test.ts +217 -0
  109. package/packages/web-crawler/vitest.config.mts +3 -0
  110. package/src/app/(backend)/webapi/models/[provider]/pull/route.ts +0 -2
  111. package/src/app/(backend)/webapi/models/[provider]/route.ts +0 -2
  112. package/src/app/(backend)/webapi/text-to-image/[provider]/route.ts +0 -2
  113. package/src/app/(backend)/webapi/trace/route.ts +0 -2
  114. package/src/components/Thinking/index.tsx +2 -3
  115. package/src/features/ChatInput/StoreUpdater.tsx +2 -0
  116. package/src/libs/traces/index.ts +1 -1
  117. package/src/server/modules/ModelRuntime/trace.ts +1 -2
  118. package/packages/const/src/settings/sync.ts +0 -5
  119. package/packages/model-runtime/src/openrouter/__snapshots__/index.test.ts.snap +0 -113
@@ -1,7 +1,7 @@
1
+ import { isDesktop } from '@lobechat/const';
1
2
  import { ProxyTRPCRequestParams, dispatch, streamInvoke } from '@lobechat/electron-client-ipc';
2
3
  import debug from 'debug';
3
4
 
4
- import { isDesktop } from '@/const/version';
5
5
  import { getElectronStoreState } from '@/store/electron';
6
6
  import { electronSyncSelectors } from '@/store/electron/selectors';
7
7
  import { getRequestBody, headersToRecord } from '@/utils/fetch';
@@ -1,20 +1,19 @@
1
+ import { LOBE_CHAT_OBSERVATION_ID, LOBE_CHAT_TRACE_ID, MESSAGE_CANCEL_FLAT } from '@lobechat/const';
1
2
  import { parseToolCalls } from '@lobechat/model-runtime';
2
- import { ChatErrorType } from '@lobechat/types';
3
-
4
- import { MESSAGE_CANCEL_FLAT } from '@/const/message';
5
- import { LOBE_CHAT_OBSERVATION_ID, LOBE_CHAT_TRACE_ID } from '@/const/trace';
6
- import { ResponseAnimation, ResponseAnimationStyle } from '@lobechat/types';
7
3
  import {
4
+ ChatErrorType,
5
+ ChatImageChunk,
8
6
  ChatMessageError,
7
+ GroundingSearch,
9
8
  MessageToolCall,
10
9
  MessageToolCallChunk,
11
10
  MessageToolCallSchema,
12
11
  ModelReasoning,
13
12
  ModelSpeed,
14
13
  ModelTokensUsage,
15
- } from '@/types/message';
16
- import { ChatImageChunk } from '@/types/message/image';
17
- import { GroundingSearch } from '@/types/search';
14
+ ResponseAnimation,
15
+ ResponseAnimationStyle,
16
+ } from '@lobechat/types';
18
17
 
19
18
  import { nanoid } from '../uuid';
20
19
  import { fetchEventSource } from './fetchEventSource';
@@ -1,8 +1,6 @@
1
- import { ErrorResponse, ErrorType } from '@lobechat/types';
1
+ import { ChatMessageError, ErrorResponse, ErrorType } from '@lobechat/types';
2
2
  import { t } from 'i18next';
3
3
 
4
- import { ChatMessageError } from '@/types/message';
5
-
6
4
  export const getMessageError = async (response: Response) => {
7
5
  let chatMessageError: ChatMessageError;
8
6
 
@@ -1,8 +1,7 @@
1
+ import { USD_TO_CNY } from '@lobechat/const';
1
2
  import dayjs from 'dayjs';
2
3
  import { describe, expect, it } from 'vitest';
3
4
 
4
- import { USD_TO_CNY } from '@/const/currency';
5
-
6
5
  import {
7
6
  formatDate,
8
7
  formatIntergerNumber,
@@ -7,4 +7,5 @@ export * from './object';
7
7
  export * from './parseModels';
8
8
  export * from './pricing';
9
9
  export * from './safeParseJSON';
10
+ export * from './sleep';
10
11
  export * from './uuid';
@@ -1,9 +1,8 @@
1
+ import { ChatCompletionTool , OpenAIPluginManifest } from '@lobechat/types';
1
2
  import { LobeChatPluginManifest, pluginManifestSchema } from '@lobehub/chat-plugin-sdk';
2
3
  import { uniqBy } from 'lodash-es';
3
4
 
4
5
  import { API_ENDPOINTS } from '@/services/_url';
5
- import { ChatCompletionTool } from '@/types/openai/chat';
6
- import { OpenAIPluginManifest } from '@/types/openai/plugin';
7
6
  import { genToolCallingName } from '@/utils/toolCall';
8
7
 
9
8
  const fetchJSON = async <T = any>(url: string, proxy = false): Promise<T> => {
@@ -1,4 +1,4 @@
1
- import { LOBE_CHAT_TRACE_HEADER, LOBE_CHAT_TRACE_ID, TracePayload } from '@/const/trace';
1
+ import { LOBE_CHAT_TRACE_HEADER, LOBE_CHAT_TRACE_ID, TracePayload } from '@lobechat/const';
2
2
 
3
3
  export const getTracePayload = (req: Request): TracePayload | undefined => {
4
4
  const header = req.headers.get(LOBE_CHAT_TRACE_HEADER);
@@ -5,13 +5,12 @@ export default defineConfig({
5
5
  test: {
6
6
  alias: {
7
7
  /* eslint-disable sort-keys-fix/sort-keys-fix */
8
- '@/types': resolve(__dirname, '../types/src'),
9
8
  '@/const': resolve(__dirname, '../const/src'),
10
- '@/libs/model-runtime': resolve(__dirname, '../model-runtime/src'),
11
9
  '@': resolve(__dirname, '../../src'),
12
10
  /* eslint-enable */
13
11
  },
14
12
  coverage: {
13
+ all: false,
15
14
  reporter: ['text', 'json', 'lcov', 'text-summary'],
16
15
  },
17
16
  environment: 'happy-dom',
@@ -0,0 +1,275 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { crawUrlRules } from '../urlRules';
4
+
5
+ describe('urlRules', () => {
6
+ it('should export an array of CrawlUrlRule objects', () => {
7
+ expect(Array.isArray(crawUrlRules)).toBe(true);
8
+ expect(crawUrlRules.length).toBeGreaterThan(0);
9
+ });
10
+
11
+ it('should have valid rule structure for each rule', () => {
12
+ crawUrlRules.forEach((rule, index) => {
13
+ expect(rule).toHaveProperty('urlPattern');
14
+ expect(typeof rule.urlPattern).toBe('string');
15
+ expect(rule.urlPattern.length).toBeGreaterThan(0);
16
+
17
+ if (rule.impls) {
18
+ expect(Array.isArray(rule.impls)).toBe(true);
19
+ expect(rule.impls.length).toBeGreaterThan(0);
20
+ }
21
+
22
+ if (rule.filterOptions) {
23
+ expect(typeof rule.filterOptions).toBe('object');
24
+ }
25
+
26
+ if (rule.urlTransform) {
27
+ expect(typeof rule.urlTransform).toBe('string');
28
+ expect(rule.urlTransform.length).toBeGreaterThan(0);
29
+ }
30
+ });
31
+ });
32
+
33
+ describe('specific URL patterns', () => {
34
+ it('should match WeChat Sogou links', () => {
35
+ const wechatRule = crawUrlRules.find(
36
+ (rule) => rule.urlPattern === 'https://weixin.sogou.com/link(.*)',
37
+ );
38
+
39
+ expect(wechatRule).toBeDefined();
40
+ expect(wechatRule?.impls).toEqual(['search1api']);
41
+ });
42
+
43
+ it('should match Sogou links', () => {
44
+ const sogouRule = crawUrlRules.find(
45
+ (rule) => rule.urlPattern === 'https://sogou.com/link(.*)',
46
+ );
47
+
48
+ expect(sogouRule).toBeDefined();
49
+ expect(sogouRule?.impls).toEqual(['search1api']);
50
+ });
51
+
52
+ it('should match YouTube links', () => {
53
+ const youtubeRule = crawUrlRules.find(
54
+ (rule) => rule.urlPattern === 'https://www.youtube.com/watch(.*)',
55
+ );
56
+
57
+ expect(youtubeRule).toBeDefined();
58
+ expect(youtubeRule?.impls).toEqual(['search1api']);
59
+ });
60
+
61
+ it('should match Reddit links', () => {
62
+ const redditRule = crawUrlRules.find(
63
+ (rule) => rule.urlPattern === 'https://www.reddit.com/r/(.*)/comments/(.*)',
64
+ );
65
+
66
+ expect(redditRule).toBeDefined();
67
+ expect(redditRule?.impls).toEqual(['search1api']);
68
+ });
69
+
70
+ it('should match WeChat public account links', () => {
71
+ const wechatPublicRule = crawUrlRules.find(
72
+ (rule) => rule.urlPattern === 'https://mp.weixin.qq.com(.*)',
73
+ );
74
+
75
+ expect(wechatPublicRule).toBeDefined();
76
+ expect(wechatPublicRule?.impls).toEqual(['search1api', 'jina']);
77
+ });
78
+
79
+ it('should match GitHub blob links with URL transformation', () => {
80
+ const githubBlobRule = crawUrlRules.find(
81
+ (rule) => rule.urlPattern === 'https://github.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)',
82
+ );
83
+
84
+ expect(githubBlobRule).toBeDefined();
85
+ expect(githubBlobRule?.impls).toEqual(['naive', 'jina']);
86
+ expect(githubBlobRule?.urlTransform).toBe('https://github.com/$1/$2/raw/refs/heads/$3/$4');
87
+ expect(githubBlobRule?.filterOptions?.enableReadability).toBe(false);
88
+ });
89
+
90
+ it('should match GitHub discussion links', () => {
91
+ const githubDiscussionRule = crawUrlRules.find(
92
+ (rule) => rule.urlPattern === 'https://github.com/(.*)/discussions/(.*)',
93
+ );
94
+
95
+ expect(githubDiscussionRule).toBeDefined();
96
+ expect(githubDiscussionRule?.impls).toEqual(['naive', 'jina']);
97
+ expect(githubDiscussionRule?.filterOptions?.enableReadability).toBe(false);
98
+ });
99
+
100
+ it('should match PDF files', () => {
101
+ const pdfRule = crawUrlRules.find((rule) => rule.urlPattern === 'https://(.*).pdf');
102
+
103
+ expect(pdfRule).toBeDefined();
104
+ expect(pdfRule?.impls).toEqual(['jina']);
105
+ });
106
+
107
+ it('should match arXiv PDF links', () => {
108
+ const arxivRule = crawUrlRules.find(
109
+ (rule) => rule.urlPattern === 'https://arxiv.org/pdf/(.*)',
110
+ );
111
+
112
+ expect(arxivRule).toBeDefined();
113
+ expect(arxivRule?.impls).toEqual(['jina']);
114
+ });
115
+
116
+ it('should match Zhihu Zhuanlan links', () => {
117
+ const zhihuZhuanlanRule = crawUrlRules.find(
118
+ (rule) => rule.urlPattern === 'https://zhuanlan.zhihu.com(.*)',
119
+ );
120
+
121
+ expect(zhihuZhuanlanRule).toBeDefined();
122
+ expect(zhihuZhuanlanRule?.impls).toEqual(['jina']);
123
+ });
124
+
125
+ it('should match Zhihu links', () => {
126
+ const zhihuRule = crawUrlRules.find((rule) => rule.urlPattern === 'https://zhihu.com(.*)');
127
+
128
+ expect(zhihuRule).toBeDefined();
129
+ expect(zhihuRule?.impls).toEqual(['jina']);
130
+ });
131
+
132
+ it('should match Medium links with URL transformation', () => {
133
+ const mediumRule = crawUrlRules.find((rule) => rule.urlPattern === 'https://medium.com/(.*)');
134
+
135
+ expect(mediumRule).toBeDefined();
136
+ expect(mediumRule?.urlTransform).toBe('https://scribe.rip/$1');
137
+ });
138
+
139
+ it('should match Twitter/X links', () => {
140
+ const twitterRule = crawUrlRules.find(
141
+ (rule) => rule.urlPattern === 'https://(twitter.com|x.com)/(.*)',
142
+ );
143
+
144
+ expect(twitterRule).toBeDefined();
145
+ expect(twitterRule?.impls).toEqual(['jina', 'browserless']);
146
+ expect(twitterRule?.filterOptions?.enableReadability).toBe(false);
147
+ });
148
+
149
+ it('should match sports data website with specific filter options', () => {
150
+ const sportsRule = crawUrlRules.find(
151
+ (rule) => rule.urlPattern === 'https://www.qiumiwu.com/standings/(.*)',
152
+ );
153
+
154
+ expect(sportsRule).toBeDefined();
155
+ expect(sportsRule?.impls).toEqual(['naive']);
156
+ expect(sportsRule?.filterOptions?.enableReadability).toBe(false);
157
+ expect(sportsRule?.filterOptions?.pureText).toBe(true);
158
+ });
159
+
160
+ it('should match Mozilla Developer docs', () => {
161
+ const mozillaRule = crawUrlRules.find(
162
+ (rule) => rule.urlPattern === 'https://developer.mozilla.org(.*)',
163
+ );
164
+
165
+ expect(mozillaRule).toBeDefined();
166
+ expect(mozillaRule?.impls).toEqual(['jina']);
167
+ });
168
+
169
+ it('should match CVPR links', () => {
170
+ const cvprRule = crawUrlRules.find(
171
+ (rule) => rule.urlPattern === 'https://cvpr.thecvf.com(.*)',
172
+ );
173
+
174
+ expect(cvprRule).toBeDefined();
175
+ expect(cvprRule?.impls).toEqual(['jina']);
176
+ });
177
+
178
+ it('should match Feishu links', () => {
179
+ const feishuRule = crawUrlRules.find(
180
+ (rule) => rule.urlPattern === 'https://(.*).feishu.cn/(.*)',
181
+ );
182
+
183
+ expect(feishuRule).toBeDefined();
184
+ expect(feishuRule?.impls).toEqual(['jina']);
185
+ });
186
+
187
+ it('should match Xiaohongshu (Little Red Book) links', () => {
188
+ const xiaohongshuRule = crawUrlRules.find(
189
+ (rule) => rule.urlPattern === 'https://(.*).xiaohongshu.com/(.*)',
190
+ );
191
+
192
+ expect(xiaohongshuRule).toBeDefined();
193
+ expect(xiaohongshuRule?.impls).toEqual(['search1api', 'jina']);
194
+ });
195
+ });
196
+
197
+ describe('URL pattern validation', () => {
198
+ it('should have valid regex patterns that can be compiled', () => {
199
+ crawUrlRules.forEach((rule, index) => {
200
+ expect(() => {
201
+ new RegExp(rule.urlPattern);
202
+ }).not.toThrow(`Rule at index ${index} should have valid regex pattern`);
203
+ });
204
+ });
205
+ });
206
+
207
+ describe('impl validation', () => {
208
+ const validImpls = ['naive', 'jina', 'browserless', 'search1api', 'exa', 'firecrawl', 'tavily'];
209
+
210
+ it('should only use valid crawler implementations', () => {
211
+ crawUrlRules.forEach((rule, index) => {
212
+ if (rule.impls) {
213
+ rule.impls.forEach((impl) => {
214
+ expect(validImpls).toContain(impl);
215
+ });
216
+ }
217
+ });
218
+ });
219
+ });
220
+
221
+ describe('filter options validation', () => {
222
+ it('should have valid filter options structure', () => {
223
+ crawUrlRules.forEach((rule, index) => {
224
+ if (rule.filterOptions) {
225
+ const { filterOptions } = rule;
226
+
227
+ if ('enableReadability' in filterOptions) {
228
+ expect(typeof filterOptions.enableReadability).toBe('boolean');
229
+ }
230
+
231
+ if ('pureText' in filterOptions) {
232
+ expect(typeof filterOptions.pureText).toBe('boolean');
233
+ }
234
+ }
235
+ });
236
+ });
237
+ });
238
+
239
+ describe('URL transformation validation', () => {
240
+ it('should have valid URL transformation templates', () => {
241
+ crawUrlRules.forEach((rule, index) => {
242
+ if (rule.urlTransform) {
243
+ // Check if URL transform contains placeholder groups
244
+ expect(rule.urlTransform).toMatch(/\$\d+/);
245
+
246
+ // Check if it's a valid URL format
247
+ expect(rule.urlTransform).toMatch(/^https?:\/\//);
248
+ }
249
+ });
250
+ });
251
+ });
252
+
253
+ describe('rule completeness', () => {
254
+ it('should cover major social media platforms', () => {
255
+ const patterns = crawUrlRules.map((rule) => rule.urlPattern);
256
+
257
+ // Check for major platforms
258
+ expect(patterns.some((p) => p.includes('youtube.com'))).toBe(true);
259
+ expect(patterns.some((p) => p.includes('reddit.com'))).toBe(true);
260
+ expect(patterns.some((p) => p.includes('twitter.com') || p.includes('x.com'))).toBe(true);
261
+ expect(patterns.some((p) => p.includes('medium.com'))).toBe(true);
262
+ expect(patterns.some((p) => p.includes('github.com'))).toBe(true);
263
+ });
264
+
265
+ it('should cover Chinese platforms', () => {
266
+ const patterns = crawUrlRules.map((rule) => rule.urlPattern);
267
+
268
+ // Check for Chinese platforms
269
+ expect(patterns.some((p) => p.includes('weixin.sogou.com'))).toBe(true);
270
+ expect(patterns.some((p) => p.includes('zhihu.com'))).toBe(true);
271
+ expect(patterns.some((p) => p.includes('xiaohongshu.com'))).toBe(true);
272
+ expect(patterns.some((p) => p.includes('feishu.cn'))).toBe(true);
273
+ });
274
+ });
275
+ });
@@ -0,0 +1,269 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { NetworkConnectionError, PageNotFoundError, TimeoutError } from '../../utils/errorType';
4
+ import { exa } from '../exa';
5
+
6
+ // Mock dependencies
7
+ vi.mock('../../utils/withTimeout', () => ({
8
+ DEFAULT_TIMEOUT: 30000,
9
+ withTimeout: vi.fn(),
10
+ }));
11
+
12
+ describe('exa crawler', () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ delete process.env.EXA_API_KEY;
16
+ });
17
+
18
+ it('should successfully crawl content with API key', async () => {
19
+ process.env.EXA_API_KEY = 'test-api-key';
20
+
21
+ const mockResponse = {
22
+ ok: true,
23
+ json: vi.fn().mockResolvedValue({
24
+ requestId: 'test-request-id',
25
+ results: [
26
+ {
27
+ id: 'test-id',
28
+ title: 'Test Article',
29
+ url: 'https://example.com',
30
+ text: 'This is a test article with enough content to pass the length check. '.repeat(3),
31
+ author: 'Test Author',
32
+ publishedDate: '2023-01-01',
33
+ summary: 'Test summary',
34
+ },
35
+ ],
36
+ }),
37
+ };
38
+
39
+ const { withTimeout } = await import('../../utils/withTimeout');
40
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
41
+
42
+ const result = await exa('https://example.com', { filterOptions: {} });
43
+
44
+ expect(result).toEqual({
45
+ content: 'This is a test article with enough content to pass the length check. '.repeat(3),
46
+ contentType: 'text',
47
+ length: 'This is a test article with enough content to pass the length check. '.repeat(3)
48
+ .length,
49
+ siteName: 'example.com',
50
+ title: 'Test Article',
51
+ url: 'https://example.com',
52
+ });
53
+
54
+ expect(withTimeout).toHaveBeenCalledWith(expect.any(Promise), 30000);
55
+ });
56
+
57
+ it('should handle missing API key', async () => {
58
+ // API key is undefined
59
+ const mockResponse = {
60
+ ok: true,
61
+ json: vi.fn().mockResolvedValue({
62
+ results: [
63
+ {
64
+ title: 'Test Article',
65
+ url: 'https://example.com',
66
+ text: 'Test content with sufficient length. '.repeat(5),
67
+ },
68
+ ],
69
+ }),
70
+ };
71
+
72
+ const { withTimeout } = await import('../../utils/withTimeout');
73
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
74
+
75
+ await exa('https://example.com', { filterOptions: {} });
76
+
77
+ // Check that fetch was called with empty API key header
78
+ expect(withTimeout).toHaveBeenCalledWith(expect.any(Promise), 30000);
79
+ });
80
+
81
+ it('should return undefined when no results are returned', async () => {
82
+ process.env.EXA_API_KEY = 'test-api-key';
83
+
84
+ const mockResponse = {
85
+ ok: true,
86
+ json: vi.fn().mockResolvedValue({
87
+ requestId: 'test-request-id',
88
+ results: [],
89
+ }),
90
+ };
91
+
92
+ const { withTimeout } = await import('../../utils/withTimeout');
93
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
94
+
95
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
96
+
97
+ const result = await exa('https://example.com', { filterOptions: {} });
98
+
99
+ expect(result).toBeUndefined();
100
+ expect(consoleSpy).toHaveBeenCalledWith(
101
+ 'Exa API returned no results for URL:',
102
+ 'https://example.com',
103
+ );
104
+
105
+ consoleSpy.mockRestore();
106
+ });
107
+
108
+ it('should return undefined for short content', async () => {
109
+ process.env.EXA_API_KEY = 'test-api-key';
110
+
111
+ const mockResponse = {
112
+ ok: true,
113
+ json: vi.fn().mockResolvedValue({
114
+ results: [
115
+ {
116
+ title: 'Test Article',
117
+ url: 'https://example.com',
118
+ text: 'Short', // Content too short
119
+ },
120
+ ],
121
+ }),
122
+ };
123
+
124
+ const { withTimeout } = await import('../../utils/withTimeout');
125
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
126
+
127
+ const result = await exa('https://example.com', { filterOptions: {} });
128
+
129
+ expect(result).toBeUndefined();
130
+ });
131
+
132
+ it('should throw PageNotFoundError for 404 status', async () => {
133
+ process.env.EXA_API_KEY = 'test-api-key';
134
+
135
+ const mockResponse = {
136
+ ok: false,
137
+ status: 404,
138
+ statusText: 'Not Found',
139
+ };
140
+
141
+ const { withTimeout } = await import('../../utils/withTimeout');
142
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
143
+
144
+ await expect(exa('https://example.com', { filterOptions: {} })).rejects.toThrow(
145
+ PageNotFoundError,
146
+ );
147
+ });
148
+
149
+ it('should throw error for other HTTP errors', async () => {
150
+ process.env.EXA_API_KEY = 'test-api-key';
151
+
152
+ const mockResponse = {
153
+ ok: false,
154
+ status: 500,
155
+ statusText: 'Internal Server Error',
156
+ };
157
+
158
+ const { withTimeout } = await import('../../utils/withTimeout');
159
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
160
+
161
+ await expect(exa('https://example.com', { filterOptions: {} })).rejects.toThrow(
162
+ 'Exa request failed with status 500: Internal Server Error',
163
+ );
164
+ });
165
+
166
+ it('should throw NetworkConnectionError for fetch failures', async () => {
167
+ process.env.EXA_API_KEY = 'test-api-key';
168
+
169
+ const { withTimeout } = await import('../../utils/withTimeout');
170
+ vi.mocked(withTimeout).mockRejectedValue(new Error('fetch failed'));
171
+
172
+ await expect(exa('https://example.com', { filterOptions: {} })).rejects.toThrow(
173
+ NetworkConnectionError,
174
+ );
175
+ });
176
+
177
+ it('should throw TimeoutError when request times out', async () => {
178
+ process.env.EXA_API_KEY = 'test-api-key';
179
+
180
+ const timeoutError = new TimeoutError('Request timeout');
181
+
182
+ const { withTimeout } = await import('../../utils/withTimeout');
183
+ vi.mocked(withTimeout).mockRejectedValue(timeoutError);
184
+
185
+ await expect(exa('https://example.com', { filterOptions: {} })).rejects.toThrow(TimeoutError);
186
+ });
187
+
188
+ it('should rethrow unknown errors', async () => {
189
+ process.env.EXA_API_KEY = 'test-api-key';
190
+
191
+ const unknownError = new Error('Unknown error');
192
+
193
+ const { withTimeout } = await import('../../utils/withTimeout');
194
+ vi.mocked(withTimeout).mockRejectedValue(unknownError);
195
+
196
+ await expect(exa('https://example.com', { filterOptions: {} })).rejects.toThrow(
197
+ 'Unknown error',
198
+ );
199
+ });
200
+
201
+ it('should return undefined when JSON parsing fails', async () => {
202
+ process.env.EXA_API_KEY = 'test-api-key';
203
+
204
+ const mockResponse = {
205
+ ok: true,
206
+ json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
207
+ };
208
+
209
+ const { withTimeout } = await import('../../utils/withTimeout');
210
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
211
+
212
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
213
+
214
+ const result = await exa('https://example.com', { filterOptions: {} });
215
+
216
+ expect(result).toBeUndefined();
217
+ expect(consoleSpy).toHaveBeenCalled();
218
+
219
+ consoleSpy.mockRestore();
220
+ });
221
+
222
+ it('should use result URL when available', async () => {
223
+ process.env.EXA_API_KEY = 'test-api-key';
224
+
225
+ const mockResponse = {
226
+ ok: true,
227
+ json: vi.fn().mockResolvedValue({
228
+ results: [
229
+ {
230
+ title: 'Test Article',
231
+ url: 'https://redirected.example.com',
232
+ text: 'Test content with sufficient length. '.repeat(5),
233
+ },
234
+ ],
235
+ }),
236
+ };
237
+
238
+ const { withTimeout } = await import('../../utils/withTimeout');
239
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
240
+
241
+ const result = await exa('https://example.com', { filterOptions: {} });
242
+
243
+ expect(result?.url).toBe('https://redirected.example.com');
244
+ });
245
+
246
+ it('should fallback to original URL when result URL is missing', async () => {
247
+ process.env.EXA_API_KEY = 'test-api-key';
248
+
249
+ const mockResponse = {
250
+ ok: true,
251
+ json: vi.fn().mockResolvedValue({
252
+ results: [
253
+ {
254
+ title: 'Test Article',
255
+ text: 'Test content with sufficient length. '.repeat(5),
256
+ // url is missing
257
+ },
258
+ ],
259
+ }),
260
+ };
261
+
262
+ const { withTimeout } = await import('../../utils/withTimeout');
263
+ vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
264
+
265
+ const result = await exa('https://example.com', { filterOptions: {} });
266
+
267
+ expect(result?.url).toBe('https://example.com');
268
+ });
269
+ });