@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.
- package/CHANGELOG.md +58 -0
- package/Dockerfile +2 -0
- package/Dockerfile.database +2 -0
- package/Dockerfile.pglite +2 -0
- package/changelog/v1.json +21 -0
- package/docs/self-hosting/advanced/online-search.mdx +123 -5
- package/docs/self-hosting/advanced/online-search.zh-CN.mdx +123 -4
- package/locales/ar/setting.json +1 -1
- package/locales/bg-BG/setting.json +1 -1
- package/locales/de-DE/setting.json +1 -1
- package/locales/en-US/setting.json +1 -1
- package/locales/es-ES/setting.json +1 -1
- package/locales/fa-IR/setting.json +1 -1
- package/locales/fr-FR/setting.json +1 -1
- package/locales/it-IT/setting.json +1 -1
- package/locales/ja-JP/setting.json +1 -1
- package/locales/ko-KR/setting.json +1 -1
- package/locales/nl-NL/setting.json +1 -1
- package/locales/pl-PL/setting.json +1 -1
- package/locales/pt-BR/setting.json +1 -1
- package/locales/ru-RU/setting.json +1 -1
- package/locales/tr-TR/setting.json +1 -1
- package/locales/vi-VN/setting.json +1 -1
- package/locales/zh-CN/setting.json +1 -1
- package/locales/zh-TW/setting.json +1 -1
- package/package.json +1 -1
- package/src/app/[variants]/(main)/settings/llm/ProviderList/providers.tsx +2 -0
- package/src/config/aiModels/index.ts +3 -0
- package/src/config/aiModels/v0.ts +63 -0
- package/src/config/llm.ts +6 -0
- package/src/config/modelProviders/index.ts +4 -0
- package/src/config/modelProviders/v0.ts +17 -0
- package/src/libs/model-runtime/runtimeMap.ts +2 -0
- package/src/libs/model-runtime/types/type.ts +1 -0
- package/src/libs/model-runtime/utils/modelParse.ts +6 -0
- package/src/libs/model-runtime/v0/index.ts +21 -0
- package/src/locales/default/setting.ts +1 -1
- package/src/server/services/search/impls/anspire/index.ts +132 -0
- package/src/server/services/search/impls/anspire/type.ts +21 -0
- package/src/server/services/search/impls/brave/index.ts +129 -0
- package/src/server/services/search/impls/brave/type.ts +58 -0
- package/src/server/services/search/impls/google/index.ts +129 -0
- package/src/server/services/search/impls/google/type.ts +53 -0
- package/src/server/services/search/impls/index.ts +24 -0
- package/src/server/services/search/impls/kagi/index.ts +111 -0
- package/src/server/services/search/impls/kagi/type.ts +24 -0
- package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +25 -3
- package/src/types/user/settings/keyVaults.ts +1 -0
- package/src/utils/client/parserPlaceholder.test.ts +0 -21
- 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,
|
@@ -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
|
+
});
|
@@ -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
|
+
}
|