@lobehub/chat 1.80.5 → 1.81.1
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 +50 -0
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/packages/electron-client-ipc/src/events/index.ts +6 -2
- package/packages/electron-client-ipc/src/events/remoteServer.ts +28 -0
- package/packages/electron-client-ipc/src/types/index.ts +1 -0
- package/packages/electron-client-ipc/src/types/remoteServer.ts +8 -0
- package/packages/electron-server-ipc/package.json +7 -1
- package/packages/electron-server-ipc/src/ipcClient.ts +54 -20
- package/packages/electron-server-ipc/src/ipcServer.ts +42 -9
- package/packages/web-crawler/src/crawImpl/__tests__/search1api.test.ts +33 -39
- package/packages/web-crawler/src/crawImpl/search1api.ts +1 -7
- package/packages/web-crawler/src/index.ts +1 -0
- package/packages/web-crawler/src/urlRules.ts +3 -1
- package/src/config/aiModels/sensenova.ts +120 -5
- package/src/config/tools.ts +2 -0
- package/src/features/Conversation/Messages/Assistant/Tool/Inspector/Debug.tsx +9 -3
- package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginState.tsx +21 -0
- package/src/features/Conversation/Messages/Assistant/Tool/Render/Arguments.tsx +1 -1
- package/src/libs/agent-runtime/sensenova/index.ts +17 -4
- package/src/libs/agent-runtime/utils/sensenovaHelpers.test.ts +108 -0
- package/src/libs/agent-runtime/utils/sensenovaHelpers.ts +30 -0
- package/src/locales/default/plugin.ts +1 -0
- package/src/server/routers/tools/{__test__/search.test.ts → search.test.ts} +27 -5
- package/src/server/routers/tools/search.ts +3 -44
- package/src/server/services/search/impls/index.ts +30 -0
- package/src/server/services/search/impls/search1api/index.ts +154 -0
- package/src/server/services/search/impls/search1api/type.ts +81 -0
- package/src/server/{modules/SearXNG.ts → services/search/impls/searxng/client.ts} +32 -2
- package/src/server/{routers/tools/__tests__ → services/search/impls/searxng}/fixtures/searXNG.ts +2 -2
- package/src/server/services/search/impls/searxng/index.test.ts +26 -0
- package/src/server/services/search/impls/searxng/index.ts +62 -0
- package/src/server/services/search/impls/type.ts +11 -0
- package/src/server/services/search/index.ts +59 -0
- package/src/store/chat/slices/builtinTool/actions/index.ts +1 -1
- package/src/store/chat/slices/builtinTool/actions/{searXNG.test.ts → search.test.ts} +30 -55
- package/src/store/chat/slices/builtinTool/actions/{searXNG.ts → search.ts} +25 -32
- package/src/tools/web-browsing/Portal/Search/Footer.tsx +1 -1
- package/src/tools/web-browsing/Portal/Search/ResultList/SearchItem/TitleExtra.tsx +2 -2
- package/src/tools/web-browsing/Portal/Search/ResultList/SearchItem/Video.tsx +9 -7
- package/src/tools/web-browsing/Portal/Search/ResultList/SearchItem/index.tsx +2 -2
- package/src/tools/web-browsing/Portal/Search/ResultList/index.tsx +3 -3
- package/src/tools/web-browsing/Portal/Search/index.tsx +4 -4
- package/src/tools/web-browsing/Portal/index.tsx +3 -1
- package/src/tools/web-browsing/Render/Search/SearchQuery/SearchView.tsx +4 -2
- package/src/tools/web-browsing/Render/Search/SearchQuery/index.tsx +6 -13
- package/src/tools/web-browsing/Render/Search/SearchResult/SearchResultItem.tsx +2 -2
- package/src/tools/web-browsing/Render/Search/SearchResult/index.tsx +5 -5
- package/src/tools/web-browsing/Render/Search/index.tsx +2 -2
- package/src/tools/web-browsing/Render/index.tsx +4 -3
- package/src/tools/web-browsing/components/SearchBar.tsx +4 -6
- package/src/tools/web-browsing/index.ts +54 -60
- package/src/tools/web-browsing/systemRole.ts +22 -13
- package/src/types/tool/search/index.ts +44 -0
- package/src/server/routers/tools/__tests__/search.test.ts +0 -48
- package/src/types/tool/search.ts +0 -48
@@ -3,10 +3,10 @@ import { TRPCError } from '@trpc/server';
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
4
4
|
|
5
5
|
import { toolsEnv } from '@/config/tools';
|
6
|
-
import { SearXNGClient } from '@/server/
|
6
|
+
import { SearXNGClient } from '@/server/services/search/impls/searxng/client';
|
7
7
|
import { SEARCH_SEARXNG_NOT_CONFIG } from '@/types/tool/search';
|
8
8
|
|
9
|
-
import { searchRouter } from '
|
9
|
+
import { searchRouter } from './search';
|
10
10
|
|
11
11
|
// Mock JWT verification
|
12
12
|
vi.mock('@/utils/server/jwt', () => ({
|
@@ -19,7 +19,7 @@ vi.mock('@lobechat/web-crawler', () => ({
|
|
19
19
|
})),
|
20
20
|
}));
|
21
21
|
|
22
|
-
vi.mock('@/server/
|
22
|
+
vi.mock('@/server/services/search/impls/searxng/client');
|
23
23
|
|
24
24
|
describe('searchRouter', () => {
|
25
25
|
const mockContext = {
|
@@ -104,7 +104,18 @@ describe('searchRouter', () => {
|
|
104
104
|
query: 'test query',
|
105
105
|
});
|
106
106
|
|
107
|
-
expect(result).toEqual(
|
107
|
+
expect(result).toEqual({
|
108
|
+
costTime: 0,
|
109
|
+
query: 'test query',
|
110
|
+
results: [
|
111
|
+
{
|
112
|
+
title: 'Test Result',
|
113
|
+
parsedUrl: 'test.com',
|
114
|
+
url: 'http://test.com',
|
115
|
+
content: 'Test content',
|
116
|
+
},
|
117
|
+
],
|
118
|
+
});
|
108
119
|
});
|
109
120
|
|
110
121
|
it('should work without specifying search engines', async () => {
|
@@ -128,7 +139,18 @@ describe('searchRouter', () => {
|
|
128
139
|
query: 'test query',
|
129
140
|
});
|
130
141
|
|
131
|
-
expect(result).toEqual(
|
142
|
+
expect(result).toEqual({
|
143
|
+
costTime: 0,
|
144
|
+
query: 'test query',
|
145
|
+
results: [
|
146
|
+
{
|
147
|
+
title: 'Test Result',
|
148
|
+
parsedUrl: 'test.com',
|
149
|
+
url: 'http://test.com',
|
150
|
+
content: 'Test content',
|
151
|
+
},
|
152
|
+
],
|
153
|
+
});
|
132
154
|
});
|
133
155
|
|
134
156
|
it('should handle search errors', async () => {
|
@@ -1,14 +1,9 @@
|
|
1
|
-
import { Crawler } from '@lobechat/web-crawler';
|
2
|
-
import { TRPCError } from '@trpc/server';
|
3
|
-
import pMap from 'p-map';
|
4
1
|
import { z } from 'zod';
|
5
2
|
|
6
|
-
import { toolsEnv } from '@/config/tools';
|
7
3
|
import { isServerMode } from '@/const/version';
|
8
4
|
import { passwordProcedure } from '@/libs/trpc/edge';
|
9
5
|
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
10
|
-
import {
|
11
|
-
import { SEARCH_SEARXNG_NOT_CONFIG } from '@/types/tool/search';
|
6
|
+
import { searchService } from '@/server/services/search';
|
12
7
|
|
13
8
|
// TODO: password procedure 未来的处理方式可能要思考下
|
14
9
|
const searchProcedure = isServerMode ? authedProcedure : passwordProcedure;
|
@@ -22,24 +17,7 @@ export const searchRouter = router({
|
|
22
17
|
}),
|
23
18
|
)
|
24
19
|
.mutation(async ({ input }) => {
|
25
|
-
|
26
|
-
|
27
|
-
// 处理全角逗号和多余空格
|
28
|
-
let envValue = envString.replaceAll(',', ',').trim();
|
29
|
-
|
30
|
-
const impls = envValue.split(',').filter(Boolean);
|
31
|
-
|
32
|
-
const crawler = new Crawler({ impls });
|
33
|
-
|
34
|
-
const results = await pMap(
|
35
|
-
input.urls,
|
36
|
-
async (url) => {
|
37
|
-
return await crawler.crawl({ impls: input.impls, url });
|
38
|
-
},
|
39
|
-
{ concurrency: 3 },
|
40
|
-
);
|
41
|
-
|
42
|
-
return { results };
|
20
|
+
return searchService.crawlPages(input);
|
43
21
|
}),
|
44
22
|
|
45
23
|
query: searchProcedure
|
@@ -56,25 +34,6 @@ export const searchRouter = router({
|
|
56
34
|
}),
|
57
35
|
)
|
58
36
|
.query(async ({ input }) => {
|
59
|
-
|
60
|
-
throw new TRPCError({ code: 'NOT_IMPLEMENTED', message: SEARCH_SEARXNG_NOT_CONFIG });
|
61
|
-
}
|
62
|
-
|
63
|
-
const client = new SearXNGClient(toolsEnv.SEARXNG_URL);
|
64
|
-
|
65
|
-
try {
|
66
|
-
return await client.search(input.query, {
|
67
|
-
categories: input.optionalParams?.searchCategories,
|
68
|
-
engines: input.optionalParams?.searchEngines,
|
69
|
-
time_range: input.optionalParams?.searchTimeRange,
|
70
|
-
});
|
71
|
-
} catch (e) {
|
72
|
-
console.error(e);
|
73
|
-
|
74
|
-
throw new TRPCError({
|
75
|
-
code: 'SERVICE_UNAVAILABLE',
|
76
|
-
message: (e as Error).message,
|
77
|
-
});
|
78
|
-
}
|
37
|
+
return await searchService.query(input.query, input.optionalParams);
|
79
38
|
}),
|
80
39
|
});
|
@@ -0,0 +1,30 @@
|
|
1
|
+
import { Search1APIImpl } from './search1api';
|
2
|
+
import { SearXNGImpl } from './searxng';
|
3
|
+
import { SearchServiceImpl } from './type';
|
4
|
+
|
5
|
+
/**
|
6
|
+
* Available search service implementations
|
7
|
+
*/
|
8
|
+
export enum SearchImplType {
|
9
|
+
SearXNG = 'searxng',
|
10
|
+
Search1API = 'search1api',
|
11
|
+
}
|
12
|
+
|
13
|
+
/**
|
14
|
+
* Create a search service implementation instance
|
15
|
+
*/
|
16
|
+
export const createSearchServiceImpl = (
|
17
|
+
type: SearchImplType = SearchImplType.SearXNG,
|
18
|
+
): SearchServiceImpl => {
|
19
|
+
switch (type) {
|
20
|
+
case SearchImplType.SearXNG: {
|
21
|
+
return new SearXNGImpl();
|
22
|
+
}
|
23
|
+
|
24
|
+
default: {
|
25
|
+
return new Search1APIImpl();
|
26
|
+
}
|
27
|
+
}
|
28
|
+
};
|
29
|
+
|
30
|
+
export type { SearchServiceImpl } from './type';
|
@@ -0,0 +1,154 @@
|
|
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 { Search1ApiResponse } from './type';
|
9
|
+
|
10
|
+
interface Search1APIQueryParams {
|
11
|
+
crawl_results?: 0 | 1;
|
12
|
+
exclude_sites?: string[];
|
13
|
+
image?: boolean;
|
14
|
+
include_sites?: string[];
|
15
|
+
language?: string;
|
16
|
+
max_results: number;
|
17
|
+
query: string;
|
18
|
+
search_service?: string;
|
19
|
+
time_range?: string;
|
20
|
+
}
|
21
|
+
|
22
|
+
const log = debug('lobe-search:search1api');
|
23
|
+
|
24
|
+
/**
|
25
|
+
* Search1API implementation of the search service
|
26
|
+
* Primarily used for web crawling
|
27
|
+
*/
|
28
|
+
export class Search1APIImpl implements SearchServiceImpl {
|
29
|
+
private get apiKey(): string | undefined {
|
30
|
+
return process.env.SEARCH1API_SEARCH_API_KEY || process.env.SEARCH1API_API_KEY;
|
31
|
+
}
|
32
|
+
|
33
|
+
private get baseUrl(): string {
|
34
|
+
// Assuming the base URL is consistent with the crawl endpoint
|
35
|
+
return 'https://api.search1api.com';
|
36
|
+
}
|
37
|
+
|
38
|
+
async query(query: string, params: SearchParams = {}): Promise<UniformSearchResponse> {
|
39
|
+
log('Starting Search1API query with query: "%s", params: %o', query, params);
|
40
|
+
const endpoint = urlJoin(this.baseUrl, '/search');
|
41
|
+
|
42
|
+
const { searchEngines } = params;
|
43
|
+
|
44
|
+
const defaultQueryParams: Search1APIQueryParams = {
|
45
|
+
crawl_results: 0, // 默认不做抓取
|
46
|
+
image: false,
|
47
|
+
max_results: 15, // Default max results
|
48
|
+
query,
|
49
|
+
};
|
50
|
+
|
51
|
+
let body: Search1APIQueryParams[] = [
|
52
|
+
{
|
53
|
+
...defaultQueryParams,
|
54
|
+
time_range:
|
55
|
+
params?.searchTimeRange && params.searchTimeRange !== 'anytime'
|
56
|
+
? params.searchTimeRange
|
57
|
+
: undefined,
|
58
|
+
},
|
59
|
+
];
|
60
|
+
|
61
|
+
if (searchEngines && searchEngines.length > 0) {
|
62
|
+
body = searchEngines.map((searchEngine) => ({
|
63
|
+
...defaultQueryParams,
|
64
|
+
|
65
|
+
max_results: parseInt((20 / searchEngines.length).toFixed(0)),
|
66
|
+
search_service: searchEngine,
|
67
|
+
time_range:
|
68
|
+
params?.searchTimeRange && params.searchTimeRange !== 'anytime'
|
69
|
+
? params.searchTimeRange
|
70
|
+
: undefined,
|
71
|
+
}));
|
72
|
+
}
|
73
|
+
|
74
|
+
// Note: Other SearchParams like searchCategories, searchEngines (beyond the first one)
|
75
|
+
// and Search1API specific params like include_sites, exclude_sites, language
|
76
|
+
// are not currently mapped.
|
77
|
+
|
78
|
+
log('Constructed request body: %o', body);
|
79
|
+
|
80
|
+
let response: Response;
|
81
|
+
const startAt = Date.now();
|
82
|
+
let costTime = 0;
|
83
|
+
try {
|
84
|
+
log('Sending request to endpoint: %s', endpoint);
|
85
|
+
response = await fetch(endpoint, {
|
86
|
+
body: JSON.stringify(body),
|
87
|
+
headers: {
|
88
|
+
'Authorization': this.apiKey ? `Bearer ${this.apiKey}` : '',
|
89
|
+
'Content-Type': 'application/json',
|
90
|
+
},
|
91
|
+
method: 'POST',
|
92
|
+
});
|
93
|
+
log('Received response with status: %d', response.status);
|
94
|
+
costTime = Date.now() - startAt;
|
95
|
+
} catch (error) {
|
96
|
+
log.extend('error')('Search1API fetch error: %o', error);
|
97
|
+
throw new TRPCError({
|
98
|
+
cause: error,
|
99
|
+
code: 'SERVICE_UNAVAILABLE',
|
100
|
+
message: 'Failed to connect to Search1API.',
|
101
|
+
});
|
102
|
+
}
|
103
|
+
|
104
|
+
if (!response.ok) {
|
105
|
+
const errorBody = await response.text();
|
106
|
+
log.extend('error')(
|
107
|
+
`Search1API request failed with status ${response.status}: %s`,
|
108
|
+
errorBody.length > 200 ? `${errorBody.slice(0, 200)}...` : errorBody,
|
109
|
+
);
|
110
|
+
throw new TRPCError({
|
111
|
+
cause: errorBody,
|
112
|
+
code: 'SERVICE_UNAVAILABLE',
|
113
|
+
message: `Search1API request failed: ${response.statusText}`,
|
114
|
+
});
|
115
|
+
}
|
116
|
+
|
117
|
+
try {
|
118
|
+
const search1ApiResponse = (await response.json()) as Search1ApiResponse[]; // Use a specific type if defined elsewhere
|
119
|
+
|
120
|
+
log('Parsed Search1API response: %o', search1ApiResponse);
|
121
|
+
|
122
|
+
const mappedResults = search1ApiResponse.flatMap((response) => {
|
123
|
+
// Map Search1API response to SearchResponse
|
124
|
+
return (response.results || []).map(
|
125
|
+
(result): UniformSearchResult => ({
|
126
|
+
category: 'general', // Default category
|
127
|
+
content: result.content || result.snippet || '', // Prioritize content, fallback to snippet
|
128
|
+
engines: [response.searchParameters?.search_service || ''],
|
129
|
+
parsedUrl: result.link ? new URL(result.link).hostname : '', // Basic URL parsing
|
130
|
+
score: 1, // Default score
|
131
|
+
title: result.title || '',
|
132
|
+
url: result.link,
|
133
|
+
}),
|
134
|
+
);
|
135
|
+
});
|
136
|
+
|
137
|
+
log('Mapped %d results to SearchResult format', mappedResults.length);
|
138
|
+
|
139
|
+
return {
|
140
|
+
costTime,
|
141
|
+
query: query,
|
142
|
+
resultNumbers: mappedResults.length,
|
143
|
+
results: mappedResults,
|
144
|
+
};
|
145
|
+
} catch (error) {
|
146
|
+
log.extend('error')('Error parsing Search1API response: %o', error);
|
147
|
+
throw new TRPCError({
|
148
|
+
cause: error,
|
149
|
+
code: 'INTERNAL_SERVER_ERROR',
|
150
|
+
message: 'Failed to parse Search1API response.',
|
151
|
+
});
|
152
|
+
}
|
153
|
+
}
|
154
|
+
}
|
@@ -0,0 +1,81 @@
|
|
1
|
+
/**
|
2
|
+
* The query you want to ask
|
3
|
+
*/
|
4
|
+
export type Query = string;
|
5
|
+
|
6
|
+
export const SEARCH1API_SUPPORT_SEARCH_SERVICE = [
|
7
|
+
'google',
|
8
|
+
'bing',
|
9
|
+
'duckduckgo',
|
10
|
+
'yahoo',
|
11
|
+
'youtube',
|
12
|
+
'x',
|
13
|
+
'reddit',
|
14
|
+
'github',
|
15
|
+
'arxiv',
|
16
|
+
'wechat',
|
17
|
+
'bilibili',
|
18
|
+
'imdb',
|
19
|
+
'wikipedia',
|
20
|
+
] as const;
|
21
|
+
|
22
|
+
/**
|
23
|
+
* The search service you want to choose
|
24
|
+
*/
|
25
|
+
export type SearchService = (typeof SEARCH1API_SUPPORT_SEARCH_SERVICE)[number];
|
26
|
+
|
27
|
+
/**
|
28
|
+
* The results you want to have
|
29
|
+
*/
|
30
|
+
export type MaxResults = number;
|
31
|
+
/**
|
32
|
+
* The results you want to crawl
|
33
|
+
*/
|
34
|
+
export type CrawlResults = number;
|
35
|
+
/**
|
36
|
+
* Search including image urls
|
37
|
+
*/
|
38
|
+
export type Image = boolean;
|
39
|
+
/**
|
40
|
+
* List of websites to include in search results
|
41
|
+
*/
|
42
|
+
export type IncludeSites = string[];
|
43
|
+
/**
|
44
|
+
* List of websites to exclude from search results
|
45
|
+
*/
|
46
|
+
export type ExcludeSites = string[];
|
47
|
+
/**
|
48
|
+
* The language preference for search results (e.g., 'en', 'zh-CN', 'fr'). Uses standard language codes.
|
49
|
+
*/
|
50
|
+
export type Language = string;
|
51
|
+
/**
|
52
|
+
* Limit search results to a specific time range
|
53
|
+
*/
|
54
|
+
export type TimeRange = 'day' | 'month' | 'year';
|
55
|
+
|
56
|
+
export interface Search1ApiSearchParameters {
|
57
|
+
crawl_results?: CrawlResults;
|
58
|
+
exclude_sites?: ExcludeSites;
|
59
|
+
image?: Image;
|
60
|
+
include_sites?: IncludeSites;
|
61
|
+
language?: Language;
|
62
|
+
max_results?: MaxResults;
|
63
|
+
query: Query;
|
64
|
+
search_service?: SearchService;
|
65
|
+
time_range?: TimeRange;
|
66
|
+
}
|
67
|
+
|
68
|
+
// Define the Search1API specific response structure based on user input
|
69
|
+
// Ideally, this would live in a dedicated types file (e.g., src/types/tool/search/search1api.ts)
|
70
|
+
interface Search1ApiResult {
|
71
|
+
content?: string;
|
72
|
+
link: string;
|
73
|
+
snippet?: string;
|
74
|
+
title?: string;
|
75
|
+
}
|
76
|
+
|
77
|
+
export interface Search1ApiResponse {
|
78
|
+
// Keeping this generic for now
|
79
|
+
results?: Search1ApiResult[];
|
80
|
+
searchParameters?: Search1ApiSearchParameters;
|
81
|
+
}
|
@@ -1,7 +1,34 @@
|
|
1
1
|
import qs from 'query-string';
|
2
2
|
import urlJoin from 'url-join';
|
3
3
|
|
4
|
-
|
4
|
+
export interface SearXNGSearchResult {
|
5
|
+
category: string;
|
6
|
+
content?: string;
|
7
|
+
engine: string;
|
8
|
+
engines: string[];
|
9
|
+
iframe_src?: string;
|
10
|
+
img_src?: string;
|
11
|
+
parsed_url: string[];
|
12
|
+
positions: number[];
|
13
|
+
publishedDate?: string | null;
|
14
|
+
score: number;
|
15
|
+
template: string;
|
16
|
+
thumbnail?: string | null;
|
17
|
+
thumbnail_src?: string | null;
|
18
|
+
title: string;
|
19
|
+
url: string;
|
20
|
+
}
|
21
|
+
|
22
|
+
export interface SearXNGSearchResponse {
|
23
|
+
answers: any[];
|
24
|
+
corrections: any[];
|
25
|
+
infoboxes: any[];
|
26
|
+
number_of_results: number;
|
27
|
+
query: string;
|
28
|
+
results: SearXNGSearchResult[];
|
29
|
+
suggestions: string[];
|
30
|
+
unresponsive_engines: any[];
|
31
|
+
}
|
5
32
|
|
6
33
|
export class SearXNGClient {
|
7
34
|
private baseUrl: string;
|
@@ -10,7 +37,10 @@ export class SearXNGClient {
|
|
10
37
|
this.baseUrl = baseUrl;
|
11
38
|
}
|
12
39
|
|
13
|
-
async search(
|
40
|
+
async search(
|
41
|
+
query: string,
|
42
|
+
optionalParams: Record<string, any> = {},
|
43
|
+
): Promise<SearXNGSearchResponse> {
|
14
44
|
try {
|
15
45
|
const { time_range, ...otherParams } = optionalParams;
|
16
46
|
|
package/src/server/{routers/tools/__tests__ → services/search/impls/searxng}/fixtures/searXNG.ts
RENAMED
@@ -1,6 +1,6 @@
|
|
1
|
-
import {
|
1
|
+
import { SearXNGSearchResponse } from '../client';
|
2
2
|
|
3
|
-
export const hetongxue:
|
3
|
+
export const hetongxue: SearXNGSearchResponse = {
|
4
4
|
answers: [
|
5
5
|
'老师好我叫何同学. 目标是做有意思的视频 合作请联系:xhaxx1123@163.com. 【何同学】我拍了一张600万人的合影...共计2条视频,包括:正片、教程等,UP主更多精彩视频,请关注UP账号。.',
|
6
6
|
],
|
@@ -0,0 +1,26 @@
|
|
1
|
+
// @vitest-environment node
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { SearXNGClient } from './client';
|
5
|
+
import { hetongxue } from './fixtures/searXNG';
|
6
|
+
import { SearXNGImpl } from './index';
|
7
|
+
|
8
|
+
vi.mock('@/config/tools', () => ({
|
9
|
+
toolsEnv: {
|
10
|
+
SEARXNG_URL: 'https://demo.com',
|
11
|
+
},
|
12
|
+
}));
|
13
|
+
|
14
|
+
describe('SearXNGImpl', () => {
|
15
|
+
describe('query', () => {
|
16
|
+
it('搜索结果超过10个', async () => {
|
17
|
+
vi.spyOn(SearXNGClient.prototype, 'search').mockResolvedValueOnce(hetongxue);
|
18
|
+
|
19
|
+
const searchImpl = new SearXNGImpl();
|
20
|
+
const results = await searchImpl.query('何同学');
|
21
|
+
|
22
|
+
// Assert
|
23
|
+
expect(results.results.length).toEqual(43);
|
24
|
+
});
|
25
|
+
});
|
26
|
+
});
|
@@ -0,0 +1,62 @@
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
2
|
+
|
3
|
+
import { toolsEnv } from '@/config/tools';
|
4
|
+
import { SearXNGClient } from '@/server/services/search/impls/searxng/client';
|
5
|
+
import { SEARCH_SEARXNG_NOT_CONFIG, UniformSearchResponse } from '@/types/tool/search';
|
6
|
+
|
7
|
+
import { SearchServiceImpl } from '../type';
|
8
|
+
|
9
|
+
/**
|
10
|
+
* SearXNG implementation of the search service
|
11
|
+
*/
|
12
|
+
export class SearXNGImpl implements SearchServiceImpl {
|
13
|
+
async query(
|
14
|
+
query: string,
|
15
|
+
params?: {
|
16
|
+
searchCategories?: string[];
|
17
|
+
searchEngines?: string[];
|
18
|
+
searchTimeRange?: string;
|
19
|
+
},
|
20
|
+
): Promise<UniformSearchResponse> {
|
21
|
+
if (!toolsEnv.SEARXNG_URL) {
|
22
|
+
throw new TRPCError({ code: 'NOT_IMPLEMENTED', message: SEARCH_SEARXNG_NOT_CONFIG });
|
23
|
+
}
|
24
|
+
|
25
|
+
const client = new SearXNGClient(toolsEnv.SEARXNG_URL);
|
26
|
+
|
27
|
+
try {
|
28
|
+
let costTime = 0;
|
29
|
+
const startAt = Date.now();
|
30
|
+
const data = await client.search(query, {
|
31
|
+
categories: params?.searchCategories,
|
32
|
+
engines: params?.searchEngines,
|
33
|
+
time_range: params?.searchTimeRange,
|
34
|
+
});
|
35
|
+
costTime = Date.now() - startAt;
|
36
|
+
|
37
|
+
return {
|
38
|
+
costTime,
|
39
|
+
query,
|
40
|
+
resultNumbers: data.number_of_results,
|
41
|
+
results: data.results.map((item) => ({
|
42
|
+
category: item.category,
|
43
|
+
content: item.content!,
|
44
|
+
engines: item.engines,
|
45
|
+
parsedUrl: item.url ? new URL(item.url).hostname : '',
|
46
|
+
publishedDate: item.publishedDate || undefined,
|
47
|
+
score: item.score,
|
48
|
+
thumbnail: item.thumbnail || undefined,
|
49
|
+
title: item.title,
|
50
|
+
url: item.url,
|
51
|
+
})),
|
52
|
+
};
|
53
|
+
} catch (e) {
|
54
|
+
console.error(e);
|
55
|
+
|
56
|
+
throw new TRPCError({
|
57
|
+
code: 'SERVICE_UNAVAILABLE',
|
58
|
+
message: (e as Error).message,
|
59
|
+
});
|
60
|
+
}
|
61
|
+
}
|
62
|
+
}
|
@@ -0,0 +1,11 @@
|
|
1
|
+
import { SearchParams, UniformSearchResponse } from '@/types/tool/search';
|
2
|
+
|
3
|
+
/**
|
4
|
+
* Search service implementation interface
|
5
|
+
*/
|
6
|
+
export interface SearchServiceImpl {
|
7
|
+
/**
|
8
|
+
* Query for search results
|
9
|
+
*/
|
10
|
+
query(query: string, params?: SearchParams): Promise<UniformSearchResponse>;
|
11
|
+
}
|
@@ -0,0 +1,59 @@
|
|
1
|
+
import { CrawlImplType, Crawler } from '@lobechat/web-crawler';
|
2
|
+
import pMap from 'p-map';
|
3
|
+
|
4
|
+
import { toolsEnv } from '@/config/tools';
|
5
|
+
import { SearchParams } from '@/types/tool/search';
|
6
|
+
|
7
|
+
import { SearchImplType, SearchServiceImpl, createSearchServiceImpl } from './impls';
|
8
|
+
|
9
|
+
const parseImplEnv = (envString: string = '') => {
|
10
|
+
// 处理全角逗号和多余空格
|
11
|
+
const envValue = envString.replaceAll(',', ',').trim();
|
12
|
+
return envValue.split(',').filter(Boolean);
|
13
|
+
};
|
14
|
+
|
15
|
+
/**
|
16
|
+
* Search service class
|
17
|
+
* Uses different implementations for different search operations
|
18
|
+
*/
|
19
|
+
export class SearchService {
|
20
|
+
private searchImpl: SearchServiceImpl;
|
21
|
+
|
22
|
+
private get crawlerImpls() {
|
23
|
+
return parseImplEnv(toolsEnv.CRAWLER_IMPLS);
|
24
|
+
}
|
25
|
+
|
26
|
+
constructor() {
|
27
|
+
const impls = this.searchImpls;
|
28
|
+
// TODO: need use turn mode
|
29
|
+
this.searchImpl = createSearchServiceImpl(impls.length > 0 ? impls[0] : undefined);
|
30
|
+
}
|
31
|
+
|
32
|
+
async crawlPages(input: { impls?: CrawlImplType[]; urls: string[] }) {
|
33
|
+
const crawler = new Crawler({ impls: this.crawlerImpls });
|
34
|
+
|
35
|
+
const results = await pMap(
|
36
|
+
input.urls,
|
37
|
+
async (url) => {
|
38
|
+
return await crawler.crawl({ impls: input.impls, url });
|
39
|
+
},
|
40
|
+
{ concurrency: 3 },
|
41
|
+
);
|
42
|
+
|
43
|
+
return { results };
|
44
|
+
}
|
45
|
+
|
46
|
+
private get searchImpls() {
|
47
|
+
return parseImplEnv(toolsEnv.SEARCH_PROVIDERS) as SearchImplType[];
|
48
|
+
}
|
49
|
+
|
50
|
+
/**
|
51
|
+
* Query for search results
|
52
|
+
*/
|
53
|
+
async query(query: string, params?: SearchParams) {
|
54
|
+
return this.searchImpl.query(query, params);
|
55
|
+
}
|
56
|
+
}
|
57
|
+
|
58
|
+
// Add a default exported instance for convenience
|
59
|
+
export const searchService = new SearchService();
|
@@ -3,7 +3,7 @@ import { StateCreator } from 'zustand/vanilla';
|
|
3
3
|
import { ChatStore } from '@/store/chat/store';
|
4
4
|
|
5
5
|
import { ChatDallEAction, dalleSlice } from './dalle';
|
6
|
-
import { SearchAction, searchSlice } from './
|
6
|
+
import { SearchAction, searchSlice } from './search';
|
7
7
|
|
8
8
|
export interface ChatBuiltinToolAction extends ChatDallEAction, SearchAction {}
|
9
9
|
|