Skip to content

Commit dc0297d

Browse files
committed
✨ feat: add builtin search with SearXNG
1 parent 4ff0b74 commit dc0297d

File tree

33 files changed

+2086
-1
lines changed

33 files changed

+2086
-1
lines changed

src/config/tools.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { createEnv } from '@t3-oss/env-nextjs';
2+
import { z } from 'zod';
3+
4+
export const getToolsConfig = () => {
5+
return createEnv({
6+
runtimeEnv: {
7+
SEARXNG_URL: process.env.SEARXNG_URL,
8+
},
9+
10+
server: {
11+
SEARXNG_URL: z.string().url().optional(),
12+
},
13+
});
14+
};
15+
16+
export const toolsEnv = getToolsConfig();

src/libs/trpc/client/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { asyncClient } from './async';
22
export { edgeClient } from './edge';
33
export * from './lambda';
4+
export * from './tools';

src/libs/trpc/client/tools.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createTRPCClient, httpBatchLink } from '@trpc/client';
2+
import superjson from 'superjson';
3+
4+
import type { ToolsRouter } from '@/server/routers/tools';
5+
6+
export const toolsClient = createTRPCClient<ToolsRouter>({
7+
links: [
8+
httpBatchLink({
9+
headers: async () => {
10+
// dynamic import to avoid circular dependency
11+
const { createHeaderWithAuth } = await import('@/services/_auth');
12+
13+
return createHeaderWithAuth();
14+
},
15+
maxURLLength: 2083,
16+
transformer: superjson,
17+
url: '/trpc/tools',
18+
}),
19+
],
20+
});

src/locales/default/tool.ts

+15
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,19 @@ export default {
77
images: '图片:',
88
prompt: '提示词',
99
},
10+
search: {
11+
createNewSearch: '创建新的搜索记录',
12+
emptyResult: '没有搜索到结果,请修改关键词后重试',
13+
includedTooltip: '当前搜索结果会进入会话的上下文中',
14+
keywords: '关键词:',
15+
scoreTooltip: '相关性分数,该分数越高说明与查询关键词越相关',
16+
searchBar: {
17+
button: '搜索',
18+
placeholder: '关键词',
19+
tooltip: '将会重新获取搜索结果,并创建一条新的总结消息',
20+
},
21+
searchEngine: '搜索引擎:',
22+
searchResult: '搜索数量:',
23+
viewMoreResults: '查看更多 {{results}} 个结果',
24+
},
1025
};

src/server/modules/SearXNG.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import qs from 'query-string';
2+
import urlJoin from 'url-join';
3+
4+
import { SearchResponse } from '@/types/tool/search';
5+
6+
export class SearXNGClient {
7+
private baseUrl: string;
8+
9+
constructor(baseUrl: string) {
10+
this.baseUrl = baseUrl;
11+
}
12+
13+
async search(query: string, engines?: string[]): Promise<SearchResponse> {
14+
try {
15+
const searchParams = qs.stringify({
16+
engines: engines?.join(','),
17+
format: 'json',
18+
q: query,
19+
});
20+
21+
const response = await fetch(urlJoin(this.baseUrl, `/search?${searchParams}`));
22+
23+
return await response.json();
24+
} catch (error) {
25+
console.error('Error searching:', error);
26+
throw error;
27+
}
28+
}
29+
}

src/server/routers/tools/__tests__/fixtures/searXNG.ts

+668
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// @vitest-environment node
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { toolsEnv } from '@/config/tools';
5+
6+
/**
7+
* This file contains the root router of your tRPC-backend
8+
*/
9+
import { createCallerFactory } from '@/libs/trpc';
10+
import { AuthContext, createContextInner } from '@/server/context';
11+
import { SearXNGClient } from '@/server/modules/SearXNG';
12+
13+
import { searchRouter } from '../search';
14+
import { hetongxue } from './fixtures/searXNG';
15+
16+
vi.mock('@/config/tools', () => ({
17+
toolsEnv: vi.fn(),
18+
}));
19+
20+
const createCaller = createCallerFactory(searchRouter);
21+
let ctx: AuthContext;
22+
let router: ReturnType<typeof createCaller>;
23+
24+
beforeEach(async () => {
25+
vi.resetAllMocks();
26+
ctx = await createContextInner({ userId: 'mock' });
27+
router = createCaller(ctx);
28+
});
29+
30+
describe('searchRouter', () => {
31+
describe('search', () => {
32+
it('搜索结果超过10个', async () => {
33+
vi.spyOn(SearXNGClient.prototype, 'search').mockResolvedValueOnce(hetongxue);
34+
35+
const results = await router.query({ query: '何同学' });
36+
37+
// Assert
38+
expect(results.results.length).toEqual(10);
39+
});
40+
});
41+
});

src/server/routers/tools/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { publicProcedure, router } from '@/libs/trpc';
22

3+
import { searchRouter } from './search';
4+
35
export const toolsRouter = router({
46
healthcheck: publicProcedure.query(() => "i'm live!"),
7+
search: searchRouter,
58
});
69

710
export type ToolsRouter = typeof toolsRouter;

src/server/routers/tools/search.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { TRPCError } from '@trpc/server';
2+
import { z } from 'zod';
3+
4+
import { toolsEnv } from '@/config/tools';
5+
import { isServerMode } from '@/const/version';
6+
import { authedProcedure, passwordProcedure, router } from '@/libs/trpc';
7+
import { SearXNGClient } from '@/server/modules/SearXNG';
8+
9+
const searchProcedure = isServerMode ? authedProcedure : passwordProcedure;
10+
11+
export const searchRouter = router({
12+
query: searchProcedure
13+
.input(
14+
z.object({
15+
query: z.string(),
16+
searchEngine: z.array(z.string()).optional(),
17+
}),
18+
)
19+
.query(async ({ input }) => {
20+
if (!toolsEnv.SEARXNG_URL) {
21+
throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'SearXNG is not configured' });
22+
}
23+
24+
const client = new SearXNGClient(toolsEnv.SEARXNG_URL);
25+
26+
return await client.search(input.query, input.searchEngine);
27+
}),
28+
});

src/services/search.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { toolsClient } from '@/libs/trpc/client';
2+
3+
class SearchService {
4+
search(query: string, searchEngine?: string[]) {
5+
return toolsClient.search.query.query({ query, searchEngine });
6+
}
7+
}
8+
9+
export const searchService = new SearchService();

src/store/chat/slices/builtinTool/action.ts

+95
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import { StateCreator } from 'zustand/vanilla';
55

66
import { useClientDataSWR } from '@/libs/swr';
77
import { fileService } from '@/services/file';
8+
import { searchService } from '@/services/search';
89
import { imageGenerationService } from '@/services/textToImage';
910
import { uploadService } from '@/services/upload';
1011
import { chatSelectors } from '@/store/chat/selectors';
1112
import { ChatStore } from '@/store/chat/store';
1213
import { useFileStore } from '@/store/file';
14+
import { CreateMessageParams } from '@/types/message';
1315
import { DallEImageItem } from '@/types/tool/dalle';
16+
import { SearchContent, SearchQuery } from '@/types/tool/search';
1417
import { setNamespace } from '@/utils/storeDebug';
18+
import { nanoid } from '@/utils/uuid';
1519

1620
const n = setNamespace('tool');
1721

@@ -21,8 +25,25 @@ const SWR_FETCH_KEY = 'FetchImageItem';
2125
*/
2226
export interface ChatBuiltinToolAction {
2327
generateImageFromPrompts: (items: DallEImageItem[], id: string) => Promise<void>;
28+
/**
29+
* 重新发起搜索
30+
* @description 会更新插件的 arguments 参数,然后再次搜索
31+
*/
32+
reSearchWithSearXNG: (
33+
id: string,
34+
data: SearchQuery,
35+
options?: { aiSummary: boolean },
36+
) => Promise<void>;
37+
saveSearXNGSearchResult: (id: string) => Promise<void>;
38+
searchWithSearXNG: (
39+
id: string,
40+
data: SearchQuery,
41+
aiSummary?: boolean,
42+
) => Promise<void | boolean>;
2443
text2image: (id: string, data: DallEImageItem[]) => Promise<void>;
44+
2545
toggleDallEImageLoading: (key: string, value: boolean) => void;
46+
toggleSearchLoading: (id: string, loading: boolean) => void;
2647
updateImageItem: (id: string, updater: (data: DallEImageItem[]) => void) => Promise<void>;
2748
useFetchDalleImageItem: (id: string) => SWRResponse;
2849
}
@@ -82,19 +103,92 @@ export const chatToolSlice: StateCreator<
82103
});
83104
});
84105
},
106+
reSearchWithSearXNG: async (id, data, options) => {
107+
get().toggleSearchLoading(id, true);
108+
await get().updatePluginArguments(id, data);
109+
110+
await get().searchWithSearXNG(id, data, options?.aiSummary);
111+
},
112+
saveSearXNGSearchResult: async (id) => {
113+
const message = chatSelectors.getMessageById(id)(get());
114+
if (!message || !message.plugin) return;
115+
116+
const { internal_addToolToAssistantMessage, internal_createMessage, openToolUI } = get();
117+
// 1. 创建一个新的 tool call message
118+
const newToolCallId = `tool_call_${nanoid()}`;
119+
120+
const toolMessage: CreateMessageParams = {
121+
content: message.content,
122+
id: undefined,
123+
parentId: message.parentId,
124+
plugin: message.plugin,
125+
pluginState: message.pluginState,
126+
role: 'tool',
127+
sessionId: get().activeId,
128+
tool_call_id: newToolCallId,
129+
topicId: get().activeTopicId,
130+
};
131+
132+
const addToolItem = async () => {
133+
if (!message.parentId || !message.plugin) return;
134+
135+
await internal_addToolToAssistantMessage(message.parentId, {
136+
id: newToolCallId,
137+
...message.plugin,
138+
});
139+
};
140+
141+
const [newMessageId] = await Promise.all([
142+
// 1. 添加 tool message
143+
internal_createMessage(toolMessage),
144+
// 2. 将这条 tool call message 插入到 ai 消息的 tools 中
145+
addToolItem(),
146+
]);
147+
148+
// 将新创建的 tool message 激活
149+
openToolUI(newMessageId, message.plugin.identifier);
150+
},
151+
searchWithSearXNG: async (id, params, aiSummary = true) => {
152+
get().toggleSearchLoading(id, true);
153+
const data = await searchService.search(params.query, params.searchEngines);
154+
await get().updatePluginState(id, data);
155+
156+
get().toggleSearchLoading(id, false);
157+
158+
// 只取前 5 个结果作为上下文
159+
const searchContent: SearchContent[] = data.results.slice(0, 5).map((item) => ({
160+
content: item.content,
161+
title: item.title,
162+
url: item.url,
163+
}));
164+
165+
await get().internal_updateMessageContent(id, JSON.stringify(searchContent));
166+
167+
// 如果没搜索到结果,那么不触发 ai 总结
168+
if (searchContent.length === 0) return;
169+
170+
// 如果 aiSummary 为 true,则会自动触发总结
171+
return aiSummary;
172+
},
85173
text2image: async (id, data) => {
86174
// const isAutoGen = settingsSelectors.isDalleAutoGenerating(useGlobalStore.getState());
87175
// if (!isAutoGen) return;
88176

89177
await get().generateImageFromPrompts(data, id);
90178
},
179+
91180
toggleDallEImageLoading: (key, value) => {
92181
set(
93182
{ dalleImageLoading: { ...get().dalleImageLoading, [key]: value } },
94183
false,
95184
n('toggleDallEImageLoading'),
96185
);
97186
},
187+
188+
toggleSearchLoading: (id, loading) => {
189+
set({ searchLoading: { ...get().searchLoading, [id]: loading } }, false, 'toggleSearchLoading');
190+
},
191+
98192
updateImageItem: async (id, updater) => {
99193
const message = chatSelectors.getMessageById(id)(get());
100194
if (!message) return;
@@ -104,6 +198,7 @@ export const chatToolSlice: StateCreator<
104198
const nextContent = produce(data, updater);
105199
await get().internal_updateMessageContent(id, JSON.stringify(nextContent));
106200
},
201+
107202
useFetchDalleImageItem: (id) =>
108203
useClientDataSWR([SWR_FETCH_KEY, id], async () => {
109204
const item = await fileService.getFile(id);

src/store/chat/slices/builtinTool/initialState.ts

+2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { FileItem } from '@/types/files';
33
export interface ChatToolState {
44
dalleImageLoading: Record<string, boolean>;
55
dalleImageMap: Record<string, FileItem>;
6+
searchLoading: Record<string, boolean>;
67
}
78

89
export const initialToolState: ChatToolState = {
910
dalleImageLoading: {},
1011
dalleImageMap: {},
12+
searchLoading: {},
1113
};

src/store/chat/slices/builtinTool/selectors.ts

+3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ const isDallEImageGenerating = (id: string) => (s: ChatStoreState) => s.dalleIma
55
const isGeneratingDallEImage = (s: ChatStoreState) =>
66
Object.values(s.dalleImageLoading).some(Boolean);
77

8+
const isSearXNGSearching = (id: string) => (s: ChatStoreState) => s.searchLoading[id];
9+
810
export const chatToolSelectors = {
911
isDallEImageGenerating,
1012
isGeneratingDallEImage,
13+
isSearXNGSearching,
1114
};

src/tools/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { LobeBuiltinTool } from '@/types/tool';
22

33
import { ArtifactsManifest } from './artifacts';
44
import { DalleManifest } from './dalle';
5+
import { WebBrowsingManifest } from './web-browsing';
56

67
export const builtinTools: LobeBuiltinTool[] = [
78
{
@@ -14,4 +15,9 @@ export const builtinTools: LobeBuiltinTool[] = [
1415
manifest: DalleManifest,
1516
type: 'builtin',
1617
},
18+
{
19+
identifier: WebBrowsingManifest.identifier,
20+
manifest: WebBrowsingManifest,
21+
type: 'builtin',
22+
},
1723
];

src/tools/portals.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
import { BuiltinPortal } from '@/types/tool';
22

3-
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {};
3+
import { WebBrowsingManifest } from './web-browsing';
4+
import WebBrowsing from './web-browsing/Portal';
5+
6+
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
7+
[WebBrowsingManifest.identifier]: WebBrowsing as BuiltinPortal,
8+
};

src/tools/renders.ts

+3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { BuiltinRender } from '@/types/tool';
22

33
import { DalleManifest } from './dalle';
44
import DalleRender from './dalle/Render';
5+
import { WebBrowsingManifest } from './web-browsing';
6+
import WebBrowsing from './web-browsing/Render';
57

68
export const BuiltinToolsRenders: Record<string, BuiltinRender> = {
79
[DalleManifest.identifier]: DalleRender as BuiltinRender,
10+
[WebBrowsingManifest.identifier]: WebBrowsing as BuiltinRender,
811
};

0 commit comments

Comments
 (0)