@lobehub/chat 1.94.17 → 1.96.0

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 (50) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/Dockerfile +2 -0
  3. package/Dockerfile.database +2 -0
  4. package/Dockerfile.pglite +2 -0
  5. package/changelog/v1.json +21 -0
  6. package/docs/self-hosting/advanced/online-search.mdx +123 -5
  7. package/docs/self-hosting/advanced/online-search.zh-CN.mdx +123 -4
  8. package/locales/ar/setting.json +1 -1
  9. package/locales/bg-BG/setting.json +1 -1
  10. package/locales/de-DE/setting.json +1 -1
  11. package/locales/en-US/setting.json +1 -1
  12. package/locales/es-ES/setting.json +1 -1
  13. package/locales/fa-IR/setting.json +1 -1
  14. package/locales/fr-FR/setting.json +1 -1
  15. package/locales/it-IT/setting.json +1 -1
  16. package/locales/ja-JP/setting.json +1 -1
  17. package/locales/ko-KR/setting.json +1 -1
  18. package/locales/nl-NL/setting.json +1 -1
  19. package/locales/pl-PL/setting.json +1 -1
  20. package/locales/pt-BR/setting.json +1 -1
  21. package/locales/ru-RU/setting.json +1 -1
  22. package/locales/tr-TR/setting.json +1 -1
  23. package/locales/vi-VN/setting.json +1 -1
  24. package/locales/zh-CN/setting.json +1 -1
  25. package/locales/zh-TW/setting.json +1 -1
  26. package/package.json +1 -1
  27. package/src/app/[variants]/(main)/settings/llm/ProviderList/providers.tsx +2 -0
  28. package/src/config/aiModels/index.ts +3 -0
  29. package/src/config/aiModels/v0.ts +63 -0
  30. package/src/config/llm.ts +6 -0
  31. package/src/config/modelProviders/index.ts +4 -0
  32. package/src/config/modelProviders/v0.ts +17 -0
  33. package/src/libs/model-runtime/runtimeMap.ts +2 -0
  34. package/src/libs/model-runtime/types/type.ts +1 -0
  35. package/src/libs/model-runtime/utils/modelParse.ts +6 -0
  36. package/src/libs/model-runtime/v0/index.ts +21 -0
  37. package/src/locales/default/setting.ts +1 -1
  38. package/src/server/services/search/impls/anspire/index.ts +132 -0
  39. package/src/server/services/search/impls/anspire/type.ts +21 -0
  40. package/src/server/services/search/impls/brave/index.ts +129 -0
  41. package/src/server/services/search/impls/brave/type.ts +58 -0
  42. package/src/server/services/search/impls/google/index.ts +129 -0
  43. package/src/server/services/search/impls/google/type.ts +53 -0
  44. package/src/server/services/search/impls/index.ts +24 -0
  45. package/src/server/services/search/impls/kagi/index.ts +111 -0
  46. package/src/server/services/search/impls/kagi/type.ts +24 -0
  47. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +25 -3
  48. package/src/types/user/settings/keyVaults.ts +1 -0
  49. package/src/utils/client/parserPlaceholder.test.ts +0 -21
  50. package/src/utils/client/parserPlaceholder.ts +2 -15
@@ -0,0 +1,63 @@
1
+ import { AIChatModelCard } from '@/types/aiModel';
2
+
3
+ const v0ChatModels: AIChatModelCard[] = [
4
+ {
5
+ abilities: {
6
+ functionCall: true,
7
+ reasoning: true,
8
+ vision: true,
9
+ },
10
+ contextWindowTokens: 512_000,
11
+ description:
12
+ 'v0-1.5-lg 模型适用于高级思考或推理任务',
13
+ displayName: 'v0-1.5-lg',
14
+ enabled: true,
15
+ id: 'v0-1.5-lg',
16
+ maxOutput: 32_000,
17
+ pricing: {
18
+ input: 15,
19
+ output: 75,
20
+ },
21
+ type: 'chat',
22
+ },
23
+ {
24
+ abilities: {
25
+ functionCall: true,
26
+ reasoning: true,
27
+ vision: true,
28
+ },
29
+ contextWindowTokens: 128_000,
30
+ description:
31
+ 'v0-1.5-md 模型适用于日常任务和用户界面(UI)生成',
32
+ displayName: 'v0-1.5-md',
33
+ enabled: true,
34
+ id: 'v0-1.5-md',
35
+ maxOutput: 32_000,
36
+ pricing: {
37
+ input: 3,
38
+ output: 15,
39
+ },
40
+ type: 'chat',
41
+ },
42
+ {
43
+ abilities: {
44
+ functionCall: true,
45
+ vision: true,
46
+ },
47
+ contextWindowTokens: 128_000,
48
+ description:
49
+ 'v0-1.0-md 模型是通过 v0 API 提供服务的旧版模型',
50
+ displayName: 'v0-1.0-md',
51
+ id: 'v0-1.0-md',
52
+ maxOutput: 32_000,
53
+ pricing: {
54
+ input: 3,
55
+ output: 15,
56
+ },
57
+ type: 'chat',
58
+ },
59
+ ];
60
+
61
+ export const allModels = [...v0ChatModels];
62
+
63
+ export default allModels;
package/src/config/llm.ts CHANGED
@@ -165,6 +165,9 @@ export const getLLMConfig = () => {
165
165
 
166
166
  ENABLED_MODELSCOPE: z.boolean(),
167
167
  MODELSCOPE_API_KEY: z.string().optional(),
168
+
169
+ ENABLED_V0: z.boolean(),
170
+ V0_API_KEY: z.string().optional(),
168
171
  },
169
172
  runtimeEnv: {
170
173
  API_KEY_SELECT_MODE: process.env.API_KEY_SELECT_MODE,
@@ -328,6 +331,9 @@ export const getLLMConfig = () => {
328
331
 
329
332
  ENABLED_MODELSCOPE: !!process.env.MODELSCOPE_API_KEY,
330
333
  MODELSCOPE_API_KEY: process.env.MODELSCOPE_API_KEY,
334
+
335
+ ENABLED_V0: !!process.env.V0_API_KEY,
336
+ V0_API_KEY: process.env.V0_API_KEY,
331
337
  },
332
338
  });
333
339
  };
@@ -45,6 +45,7 @@ import TaichuProvider from './taichu';
45
45
  import TencentcloudProvider from './tencentcloud';
46
46
  import TogetherAIProvider from './togetherai';
47
47
  import UpstageProvider from './upstage';
48
+ import V0Provider from './v0';
48
49
  import VertexAIProvider from './vertexai';
49
50
  import VLLMProvider from './vllm';
50
51
  import VolcengineProvider from './volcengine';
@@ -83,6 +84,7 @@ export const LOBE_DEFAULT_MODEL_LIST: ChatModelCard[] = [
83
84
  JinaProvider.chatModels,
84
85
  SambaNovaProvider.chatModels,
85
86
  CohereProvider.chatModels,
87
+ V0Provider.chatModels,
86
88
  ZeroOneProvider.chatModels,
87
89
  StepfunProvider.chatModels,
88
90
  NovitaProvider.chatModels,
@@ -139,6 +141,7 @@ export const DEFAULT_MODEL_PROVIDER_LIST = [
139
141
  JinaProvider,
140
142
  SambaNovaProvider,
141
143
  CohereProvider,
144
+ V0Provider,
142
145
  QwenProvider,
143
146
  WenxinProvider,
144
147
  TencentcloudProvider,
@@ -218,6 +221,7 @@ export { default as TaichuProviderCard } from './taichu';
218
221
  export { default as TencentCloudProviderCard } from './tencentcloud';
219
222
  export { default as TogetherAIProviderCard } from './togetherai';
220
223
  export { default as UpstageProviderCard } from './upstage';
224
+ export { default as V0ProviderCard } from './v0';
221
225
  export { default as VertexAIProviderCard } from './vertexai';
222
226
  export { default as VLLMProviderCard } from './vllm';
223
227
  export { default as VolcengineProviderCard } from './volcengine';
@@ -0,0 +1,17 @@
1
+ import { ModelProviderCard } from '@/types/llm';
2
+
3
+ const V0: ModelProviderCard = {
4
+ chatModels: [],
5
+ checkModel: 'v0-1.5-md',
6
+ description:
7
+ 'v0 是一个配对编程助手,你只需用自然语言描述想法,它就能为你的项目生成代码和用户界面(UI)',
8
+ id: 'v0',
9
+ modelsUrl: 'https://vercel.com/docs/v0/api#models',
10
+ name: 'Vercel (v0)',
11
+ settings: {
12
+ sdkType: 'openai',
13
+ },
14
+ url: 'https://v0.dev',
15
+ };
16
+
17
+ export default V0;
@@ -43,6 +43,7 @@ import { LobeTaichuAI } from './taichu';
43
43
  import { LobeTencentCloudAI } from './tencentcloud';
44
44
  import { LobeTogetherAI } from './togetherai';
45
45
  import { LobeUpstageAI } from './upstage';
46
+ import { LobeV0AI } from './v0';
46
47
  import { LobeVLLMAI } from './vllm';
47
48
  import { LobeVolcengineAI } from './volcengine';
48
49
  import { LobeWenxinAI } from './wenxin';
@@ -97,6 +98,7 @@ export const providerRuntimeMap = {
97
98
  tencentcloud: LobeTencentCloudAI,
98
99
  togetherai: LobeTogetherAI,
99
100
  upstage: LobeUpstageAI,
101
+ v0: LobeV0AI,
100
102
  vllm: LobeVLLMAI,
101
103
  volcengine: LobeVolcengineAI,
102
104
  wenxin: LobeWenxinAI,
@@ -67,6 +67,7 @@ export enum ModelProvider {
67
67
  TencentCloud = 'tencentcloud',
68
68
  TogetherAI = 'togetherai',
69
69
  Upstage = 'upstage',
70
+ V0 = 'v0',
70
71
  VLLM = 'vllm',
71
72
  VertexAI = 'vertexai',
72
73
  Volcengine = 'volcengine',
@@ -48,6 +48,11 @@ export const MODEL_LIST_CONFIGS = {
48
48
  reasoningKeywords: ['qvq', 'qwq', 'qwen3'],
49
49
  visionKeywords: ['qvq', 'vl'],
50
50
  },
51
+ v0: {
52
+ functionCallKeywords: ['v0'],
53
+ reasoningKeywords: ['v0-1.5'],
54
+ visionKeywords: ['v0'],
55
+ },
51
56
  volcengine: {
52
57
  functionCallKeywords: ['doubao-1.5'],
53
58
  reasoningKeywords: ['thinking', '-r1'],
@@ -72,6 +77,7 @@ export const PROVIDER_DETECTION_CONFIG = {
72
77
  llama: ['llama'],
73
78
  openai: ['o1', 'o3', 'o4', 'gpt-'],
74
79
  qwen: ['qwen', 'qwq', 'qvq'],
80
+ v0: ['v0'],
75
81
  volcengine: ['doubao'],
76
82
  zeroone: ['yi-'],
77
83
  zhipu: ['glm'],
@@ -0,0 +1,21 @@
1
+ import { ModelProvider } from '../types';
2
+ import { processMultiProviderModelList } from '../utils/modelParse';
3
+ import { createOpenAICompatibleRuntime } from '../utils/openaiCompatibleFactory';
4
+
5
+ export interface V0ModelCard {
6
+ id: string;
7
+ }
8
+
9
+ export const LobeV0AI = createOpenAICompatibleRuntime({
10
+ baseURL: 'https://api.v0.dev/v1',
11
+ debug: {
12
+ chatCompletion: () => process.env.DEBUG_V0_CHAT_COMPLETION === '1',
13
+ },
14
+ models: async ({ client }) => {
15
+ const modelsPage = (await client.models.list()) as any;
16
+ const modelList: V0ModelCard[] = modelsPage.data;
17
+
18
+ return processMultiProviderModelList(modelList);
19
+ },
20
+ provider: ModelProvider.V0,
21
+ });
@@ -224,7 +224,7 @@ export default {
224
224
  },
225
225
  inputTemplate: {
226
226
  desc: '用户最新的一条消息会填充到此模板',
227
- placeholder: '预处理模版 {{input_template}} 将替换为实时输入信息',
227
+ placeholder: '预处理模版 {{text}} 将替换为实时输入信息',
228
228
  title: '用户输入预处理',
229
229
  },
230
230
  submit: '更新聊天偏好',
@@ -0,0 +1,132 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import debug from 'debug';
3
+ import urlJoin from 'url-join';
4
+
5
+ import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
6
+
7
+ import { SearchServiceImpl } from '../type';
8
+ import { AnspireSearchParameters, AnspireResponse } from './type';
9
+
10
+ const log = debug('lobe-search:Anspire');
11
+
12
+ /**
13
+ * Anspire implementation of the search service
14
+ * Primarily used for web crawling
15
+ */
16
+ export class AnspireImpl implements SearchServiceImpl {
17
+ private get apiKey(): string | undefined {
18
+ return process.env.ANSPIRE_API_KEY;
19
+ }
20
+
21
+ private get baseUrl(): string {
22
+ // Assuming the base URL is consistent with the crawl endpoint
23
+ return 'https://plugin.anspire.cn/api';
24
+ }
25
+
26
+ async query(query: string, params: SearchParams = {}): Promise<UniformSearchResponse> {
27
+ log('Starting Anspire query with query: "%s", params: %o', query, params);
28
+ const endpoint = urlJoin(this.baseUrl, '/ntsearch/search');
29
+
30
+ const defaultQueryParams: AnspireSearchParameters = {
31
+ mode: 0,
32
+ query,
33
+ top_k: 20,
34
+ };
35
+
36
+ let body: AnspireSearchParameters = {
37
+ ...defaultQueryParams,
38
+ ...(params?.searchTimeRange && params.searchTimeRange !== 'anytime'
39
+ ? (() => {
40
+ const now = Date.now();
41
+ const days = { day: 1, month: 30, week: 7, year: 365 }[params.searchTimeRange!];
42
+
43
+ if (days === undefined) return {};
44
+
45
+ return {
46
+ FromTime: new Date(now - days * 86_400 * 1000).toISOString().slice(0, 19).replace('T', ' '),
47
+ ToTime: new Date(now).toISOString().slice(0, 19).replace('T', ' '),
48
+ };
49
+ })()
50
+ : {}),
51
+ };
52
+
53
+ log('Constructed request body: %o', body);
54
+
55
+ const searchParams = new URLSearchParams();
56
+ for (const [key, value] of Object.entries(body)) {
57
+ searchParams.append(key, String(value));
58
+ }
59
+
60
+ let response: Response;
61
+ const startAt = Date.now();
62
+ let costTime = 0;
63
+ try {
64
+ log('Sending request to endpoint: %s', endpoint);
65
+ response = await fetch(`${endpoint}?${searchParams.toString()}`, {
66
+ headers: {
67
+ 'Accept': '*/*',
68
+ 'Authorization': this.apiKey ? `Bearer ${this.apiKey}` : '',
69
+ 'Connection': 'keep-alive ',
70
+ 'Content-Type': 'application/json',
71
+ },
72
+ method: 'GET',
73
+ });
74
+ log('Received response with status: %d', response.status);
75
+ costTime = Date.now() - startAt;
76
+ } catch (error) {
77
+ log.extend('error')('Anspire fetch error: %o', error);
78
+ throw new TRPCError({
79
+ cause: error,
80
+ code: 'SERVICE_UNAVAILABLE',
81
+ message: 'Failed to connect to Anspire.',
82
+ });
83
+ }
84
+
85
+ if (!response.ok) {
86
+ const errorBody = await response.text();
87
+ log.extend('error')(
88
+ `Anspire request failed with status ${response.status}: %s`,
89
+ errorBody.length > 200 ? `${errorBody.slice(0, 200)}...` : errorBody,
90
+ );
91
+ throw new TRPCError({
92
+ cause: errorBody,
93
+ code: 'SERVICE_UNAVAILABLE',
94
+ message: `Anspire request failed: ${response.statusText}`,
95
+ });
96
+ }
97
+
98
+ try {
99
+ const anspireResponse = (await response.json()) as AnspireResponse;
100
+
101
+ log('Parsed Anspire response: %o', anspireResponse);
102
+
103
+ const mappedResults = (anspireResponse.results || []).map(
104
+ (result): UniformSearchResult => ({
105
+ category: 'general', // Default category
106
+ content: result.content || '', // Prioritize content
107
+ engines: ['anspire'], // Use 'anspire' as the engine name
108
+ parsedUrl: result.url ? new URL(result.url).hostname : '', // Basic URL parsing
109
+ score: result.score || 0, // Default score to 0 if undefined
110
+ title: result.title || '',
111
+ url: result.url,
112
+ }),
113
+ );
114
+
115
+ log('Mapped %d results to SearchResult format', mappedResults.length);
116
+
117
+ return {
118
+ costTime,
119
+ query: query,
120
+ resultNumbers: mappedResults.length,
121
+ results: mappedResults,
122
+ };
123
+ } catch (error) {
124
+ log.extend('error')('Error parsing Anspire response: %o', error);
125
+ throw new TRPCError({
126
+ cause: error,
127
+ code: 'INTERNAL_SERVER_ERROR',
128
+ message: 'Failed to parse Anspire response.',
129
+ });
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,21 @@
1
+ export interface AnspireSearchParameters {
2
+ FromTime?: string;
3
+ Insite?: string;
4
+ ToTime?: string;
5
+ mode?: number;
6
+ query: string;
7
+ top_k?: number;
8
+ }
9
+
10
+ interface AnspireResults {
11
+ content?: string;
12
+ score?: number;
13
+ title: string;
14
+ url: string;
15
+ }
16
+
17
+ export interface AnspireResponse {
18
+ Uuid?: string;
19
+ query?: string;
20
+ results?: AnspireResults[];
21
+ }
@@ -0,0 +1,129 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import debug from 'debug';
3
+ import urlJoin from 'url-join';
4
+
5
+ import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
6
+
7
+ import { SearchServiceImpl } from '../type';
8
+ import { BraveSearchParameters, BraveResponse } from './type';
9
+
10
+ const log = debug('lobe-search:Brave');
11
+
12
+ const timeRangeMapping = {
13
+ day: 'pd',
14
+ month: 'pm',
15
+ week: 'pw',
16
+ year: 'py',
17
+ };
18
+
19
+ /**
20
+ * Brave implementation of the search service
21
+ * Primarily used for web crawling
22
+ */
23
+ export class BraveImpl implements SearchServiceImpl {
24
+ private get apiKey(): string | undefined {
25
+ return process.env.BRAVE_API_KEY;
26
+ }
27
+
28
+ private get baseUrl(): string {
29
+ // Assuming the base URL is consistent with the crawl endpoint
30
+ return 'https://api.search.brave.com/res/v1';
31
+ }
32
+
33
+ async query(query: string, params: SearchParams = {}): Promise<UniformSearchResponse> {
34
+ log('Starting Brave query with query: "%s", params: %o', query, params);
35
+ const endpoint = urlJoin(this.baseUrl, '/web/search');
36
+
37
+ const defaultQueryParams: BraveSearchParameters = {
38
+ count: 15,
39
+ q: query,
40
+ result_filter: 'web',
41
+ };
42
+
43
+ let body: BraveSearchParameters = {
44
+ ...defaultQueryParams,
45
+ freshness:
46
+ params?.searchTimeRange && params.searchTimeRange !== 'anytime'
47
+ ? timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined
48
+ : undefined,
49
+ };
50
+
51
+ log('Constructed request body: %o', body);
52
+
53
+ const searchParams = new URLSearchParams();
54
+ for (const [key, value] of Object.entries(body)) {
55
+ searchParams.append(key, String(value));
56
+ }
57
+
58
+ let response: Response;
59
+ const startAt = Date.now();
60
+ let costTime = 0;
61
+ try {
62
+ log('Sending request to endpoint: %s', endpoint);
63
+ response = await fetch(`${endpoint}?${searchParams.toString()}`, {
64
+ headers: {
65
+ 'Accept': 'application/json',
66
+ 'Accept-Encoding': 'gzip',
67
+ 'X-Subscription-Token': this.apiKey ? this.apiKey : '',
68
+ },
69
+ method: 'GET',
70
+ });
71
+ log('Received response with status: %d', response.status);
72
+ costTime = Date.now() - startAt;
73
+ } catch (error) {
74
+ log.extend('error')('Brave fetch error: %o', error);
75
+ throw new TRPCError({
76
+ cause: error,
77
+ code: 'SERVICE_UNAVAILABLE',
78
+ message: 'Failed to connect to Brave.',
79
+ });
80
+ }
81
+
82
+ if (!response.ok) {
83
+ const errorBody = await response.text();
84
+ log.extend('error')(
85
+ `Brave request failed with status ${response.status}: %s`,
86
+ errorBody.length > 200 ? `${errorBody.slice(0, 200)}...` : errorBody,
87
+ );
88
+ throw new TRPCError({
89
+ cause: errorBody,
90
+ code: 'SERVICE_UNAVAILABLE',
91
+ message: `Brave request failed: ${response.statusText}`,
92
+ });
93
+ }
94
+
95
+ try {
96
+ const braveResponse = (await response.json()) as BraveResponse;
97
+
98
+ log('Parsed Brave response: %o', braveResponse);
99
+
100
+ const mappedResults = (braveResponse.web.results || []).map(
101
+ (result): UniformSearchResult => ({
102
+ category: 'general', // Default category
103
+ content: result.description || '', // Prioritize content
104
+ engines: ['brave'], // Use 'brave' as the engine name
105
+ parsedUrl: result.url ? new URL(result.url).hostname : '', // Basic URL parsing
106
+ score: 1, // Default score to 1
107
+ title: result.title || '',
108
+ url: result.url,
109
+ }),
110
+ );
111
+
112
+ log('Mapped %d results to SearchResult format', mappedResults.length);
113
+
114
+ return {
115
+ costTime,
116
+ query: query,
117
+ resultNumbers: mappedResults.length,
118
+ results: mappedResults,
119
+ };
120
+ } catch (error) {
121
+ log.extend('error')('Error parsing Brave response: %o', error);
122
+ throw new TRPCError({
123
+ cause: error,
124
+ code: 'INTERNAL_SERVER_ERROR',
125
+ message: 'Failed to parse Brave response.',
126
+ });
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,58 @@
1
+ export interface BraveSearchParameters {
2
+ count?: number;
3
+ country?: string;
4
+ enable_rich_callback?: boolean;
5
+ extra_snippets?: boolean;
6
+ freshness?: string;
7
+ goggles?: string[];
8
+ goggles_id?: string;
9
+ offset?: number;
10
+ q: string;
11
+ result_filter?: string;
12
+ safesearch?: string;
13
+ search_lang?: string;
14
+ spellcheck?: boolean;
15
+ summary?: boolean;
16
+ text_decorations?: boolean;
17
+ ui_lang?: string;
18
+ units?: string;
19
+ }
20
+
21
+ interface BraveResults {
22
+ age?: string;
23
+ description: string;
24
+ family_friendly?: boolean;
25
+ is_live?: boolean;
26
+ is_source_both?: boolean;
27
+ is_source_local?: boolean;
28
+ language?: string;
29
+ meta_url?: any;
30
+ page_age?: string;
31
+ profile?: any;
32
+ subtype?: string;
33
+ thumbnail?: any;
34
+ title: string;
35
+ type: string;
36
+ url: string;
37
+ video?: any;
38
+ }
39
+
40
+ interface BraveVideos {
41
+ mutated_by_goggles?: boolean;
42
+ results: BraveResults[];
43
+ type: string;
44
+ }
45
+
46
+ interface BraveWeb {
47
+ family_friendly?: boolean;
48
+ results: BraveResults[];
49
+ type: string;
50
+ }
51
+
52
+ export interface BraveResponse {
53
+ mixed: any;
54
+ query?: any;
55
+ type: string;
56
+ videos?: BraveVideos;
57
+ web: BraveWeb;
58
+ }
@@ -0,0 +1,129 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import debug from 'debug';
3
+ import urlJoin from 'url-join';
4
+
5
+ import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
6
+
7
+ import { SearchServiceImpl } from '../type';
8
+ import { GoogleSearchParameters, GoogleResponse } from './type';
9
+
10
+ const log = debug('lobe-search:Google');
11
+
12
+ const timeRangeMapping = {
13
+ day: 'd1',
14
+ month: 'm1',
15
+ week: 'w1',
16
+ year: 'y1',
17
+ };
18
+
19
+ /**
20
+ * Google implementation of the search service
21
+ * Primarily used for web crawling
22
+ */
23
+ export class GoogleImpl implements SearchServiceImpl {
24
+ private get apiKey(): string | undefined {
25
+ return process.env.GOOGLE_PSE_API_KEY;
26
+ }
27
+
28
+ private get engineId(): string | undefined {
29
+ return process.env.GOOGLE_PSE_ENGINE_ID;
30
+ }
31
+
32
+ private get baseUrl(): string {
33
+ // Assuming the base URL is consistent with the crawl endpoint
34
+ return 'https://www.googleapis.com';
35
+ }
36
+
37
+ async query(query: string, params: SearchParams = {}): Promise<UniformSearchResponse> {
38
+ log('Starting Google query with query: "%s", params: %o', query, params);
39
+ const endpoint = urlJoin(this.baseUrl, '/customsearch/v1');
40
+
41
+ const defaultQueryParams: GoogleSearchParameters = {
42
+ cx: this.engineId || '',
43
+ key: this.apiKey || '',
44
+ num: 10,
45
+ q: query,
46
+ };
47
+
48
+ let body: GoogleSearchParameters = {
49
+ ...defaultQueryParams,
50
+ dateRestrict:
51
+ params?.searchTimeRange && params.searchTimeRange !== 'anytime'
52
+ ? timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined
53
+ : undefined,
54
+ };
55
+
56
+ log('Constructed request body: %o', body);
57
+
58
+ const searchParams = new URLSearchParams();
59
+ for (const [key, value] of Object.entries(body)) {
60
+ searchParams.append(key, String(value));
61
+ }
62
+
63
+ let response: Response;
64
+ const startAt = Date.now();
65
+ let costTime = 0;
66
+ try {
67
+ log('Sending request to endpoint: %s', endpoint);
68
+ response = await fetch(`${endpoint}?${searchParams.toString()}`, {
69
+ method: 'GET',
70
+ });
71
+ log('Received response with status: %d', response.status);
72
+ costTime = Date.now() - startAt;
73
+ } catch (error) {
74
+ log.extend('error')('Google fetch error: %o', error);
75
+ throw new TRPCError({
76
+ cause: error,
77
+ code: 'SERVICE_UNAVAILABLE',
78
+ message: 'Failed to connect to Google.',
79
+ });
80
+ }
81
+
82
+ if (!response.ok) {
83
+ const errorBody = await response.text();
84
+ log.extend('error')(
85
+ `Google request failed with status ${response.status}: %s`,
86
+ errorBody.length > 200 ? `${errorBody.slice(0, 200)}...` : errorBody,
87
+ );
88
+ throw new TRPCError({
89
+ cause: errorBody,
90
+ code: 'SERVICE_UNAVAILABLE',
91
+ message: `Google request failed: ${response.statusText}`,
92
+ });
93
+ }
94
+
95
+ try {
96
+ const googleResponse = (await response.json()) as GoogleResponse;
97
+
98
+ log('Parsed Google response: %o', googleResponse);
99
+
100
+ const mappedResults = (googleResponse.items || []).map(
101
+ (result): UniformSearchResult => ({
102
+ category: 'general', // Default category
103
+ content: result.snippet || '', // Prioritize content
104
+ engines: ['google'], // Use 'google' as the engine name
105
+ parsedUrl: result.link ? new URL(result.link).hostname : '', // Basic URL parsing
106
+ score: 1, // Default score to 1
107
+ title: result.title || '',
108
+ url: result.link,
109
+ }),
110
+ );
111
+
112
+ log('Mapped %d results to SearchResult format', mappedResults.length);
113
+
114
+ return {
115
+ costTime,
116
+ query: query,
117
+ resultNumbers: mappedResults.length,
118
+ results: mappedResults,
119
+ };
120
+ } catch (error) {
121
+ log.extend('error')('Error parsing Google response: %o', error);
122
+ throw new TRPCError({
123
+ cause: error,
124
+ code: 'INTERNAL_SERVER_ERROR',
125
+ message: 'Failed to parse Google response.',
126
+ });
127
+ }
128
+ }
129
+ }