🐛 fix: fix mcp server connect issue and refactor web search implement (#9694)

* add

* clean

* refactor

* refactor

* add test

* fix style

* refactor to improve search performance

* refactor types

* refactor types

* refactor types

* fix mcp retry issue

* add more tests

* fix test and types

* fix test

* fix desktop remote streamable http

* add local

* fix tests

* update
This commit is contained in:
Arvin Xu
2025-10-14 04:58:47 +02:00
committed by GitHub
parent cb986040d5
commit 15ebcb414b
94 changed files with 1321 additions and 561 deletions
+1
View File
@@ -2,6 +2,7 @@ export * from './auth';
export * from './branding';
export * from './currency';
export * from './desktop';
export * from './discover';
export * from './guide';
export * from './layoutTokens';
export * from './message';
@@ -243,8 +243,6 @@ describe('ContextEngine', () => {
expect(result.stats.processedCount).toBe(2);
expect(result.stats.totalDuration).toBeGreaterThanOrEqual(20);
expect(result.stats.processorDurations.p1).toBeGreaterThanOrEqual(10);
expect(result.stats.processorDurations.p2).toBeGreaterThanOrEqual(20);
});
it('should stop processing when aborted', async () => {
+2 -3
View File
@@ -1,9 +1,8 @@
import { LobeTool } from '@lobechat/types';
import { and, desc, eq } from 'drizzle-orm';
import { LobeChatDatabase } from '../type';
import { LobeTool } from '@/types/tool';
import { InstalledPluginItem, NewInstalledPlugin, userInstalledPlugins } from '../schemas';
import { LobeChatDatabase } from '../type';
export class PluginModel {
private userId: string;
+1
View File
@@ -34,6 +34,7 @@ export { LobeZeroOneAI } from './providers/zeroone';
export { LobeZhipuAI } from './providers/zhipu';
export * from './types';
export * from './types/error';
export { consumeStreamUntilDone } from './utils/consumeStream';
export { AgentRuntimeError } from './utils/createError';
export { getModelPropertyWithFallback } from './utils/getFallbackModelProperty';
export { getModelPricing } from './utils/getModelPricing';
+4 -1
View File
@@ -1,4 +1,4 @@
import { ModelSpeed, ModelTokensUsage, ModelUsage } from '@/types/message';
import { ModelSpeed, ModelTokensUsage, ModelUsage } from '@lobechat/types';
import { MessageToolCall, MessageToolCallChunk } from './toolsCalling';
@@ -207,6 +207,9 @@ export interface ChatStreamCallbacks {
onThinking?: (content: string) => Promise<void> | void;
onToolsCalling?: (data: {
chunk: MessageToolCallChunk[];
/**
* full tools calling array
*/
toolsCalling: MessageToolCall[];
}) => Promise<void> | void;
onUsage?: (usage: ModelTokensUsage) => Promise<void> | void;
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import { consumeStreamUntilDone } from './consumeStream';
describe('consumeStreamUntilDone', () => {
it('should consume a stream completely', async () => {
const chunks = ['chunk1', 'chunk2', 'chunk3'];
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
const response = new Response(stream);
await consumeStreamUntilDone(response);
// Stream should be consumed (reader should be locked and released)
expect(response.body?.locked).toBe(false);
});
it('should handle response without body', async () => {
const response = new Response(null);
await expect(consumeStreamUntilDone(response)).resolves.toBeUndefined();
});
it('should release reader lock even when stream errors occur', async () => {
const stream = new ReadableStream({
start(controller) {
controller.error(new Error('Stream error'));
},
});
const response = new Response(stream);
await expect(consumeStreamUntilDone(response)).rejects.toThrow('Stream error');
// Reader lock should still be released
expect(response.body?.locked).toBe(false);
});
it('should handle empty stream', async () => {
const stream = new ReadableStream({
start(controller) {
controller.close();
},
});
const response = new Response(stream);
await expect(consumeStreamUntilDone(response)).resolves.toBeUndefined();
});
it('should consume stream with large number of chunks', async () => {
const chunkCount = 100;
const stream = new ReadableStream({
start(controller) {
for (let i = 0; i < chunkCount; i++) {
controller.enqueue(new TextEncoder().encode(`chunk${i}`));
}
controller.close();
},
});
const response = new Response(stream);
await expect(consumeStreamUntilDone(response)).resolves.toBeUndefined();
expect(response.body?.locked).toBe(false);
});
it('should ensure reader.releaseLock is called', async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('test'));
controller.close();
},
});
const response = new Response(stream);
const reader = response.body!.getReader();
const releaseLockSpy = vi.spyOn(reader, 'releaseLock');
reader.releaseLock(); // Release the lock we just acquired
await consumeStreamUntilDone(response);
// The function should acquire a new reader and release it
expect(releaseLockSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,35 @@
/**
* Consumes a Response stream completely to ensure all callbacks are executed
* @param response - The Response object with a ReadableStream body
* @returns Promise that resolves when the stream is fully consumed
*
* @example
* ```ts
* const response = await modelRuntime.chat(payload, {
* callback: {
* onText: async (text) => {
* await saveToDatabase(text);
* }
* }
* });
*
* // Ensure all callbacks complete before proceeding
* await consumeStreamUntilDone(response);
* ```
*/
export async function consumeStreamUntilDone(response: Response): Promise<void> {
if (!response.body) {
return;
}
const reader = response.body.getReader();
try {
// eslint-disable-next-line no-constant-condition
while (true) {
const { done } = await reader.read();
if (done) break;
}
} finally {
reader.releaseLock();
}
}
-1
View File
@@ -6,7 +6,6 @@ export type ResponseAnimation =
| {
speed?: number;
text?: ResponseAnimationStyle;
toolsCalling?: ResponseAnimationStyle;
}
| ResponseAnimationStyle;
+1
View File
@@ -16,6 +16,7 @@ export * from './knowledgeBase';
export * from './llm';
export * from './message';
export * from './meta';
export * from './plugins';
export * from './rag';
export * from './search';
export * from './serverConfig';
+1 -1
View File
@@ -2,7 +2,7 @@ import { IPluginErrorType } from '@lobehub/chat-plugin-sdk';
import type { PartialDeep } from 'type-fest';
import { z } from 'zod';
import { LobeToolRenderType } from '@/types/tool';
import { LobeToolRenderType } from '../tool';
export interface ChatPluginPayload {
apiName: string;
-51
View File
@@ -1,53 +1,2 @@
import { ConnectionType } from '@lobehub/market-sdk';
interface PluginCompatibility {
maxAppVersion?: string;
minAppVersion?: string;
platforms?: string[];
}
export interface PluginItem {
authRequirement?: string;
author?: string;
capabilities: {
prompts: boolean;
resources: boolean;
tools: boolean;
};
category?: string;
commentCount?: number;
compatibility?: PluginCompatibility;
connectionType?: ConnectionType;
createdAt: string;
description: string;
homepage?: string;
icon?: string;
identifier: string;
installCount?: number;
isInstallable?: boolean;
isLatest: boolean;
isValidated?: boolean;
manifestUrl?: string;
manifestVersion: string;
name: string;
promptsCount?: number;
ratingAverage?: number;
ratingCount?: number;
resourcesCount?: number;
tags?: string[];
toolsCount?: number;
version: string;
}
export interface PluginListResponse {
categories: string[];
currentPage: number;
items: PluginItem[];
pageSize: number;
tags: string[];
totalCount: number;
totalPages: number;
}
export * from './mcp';
export * from './mcpDeps';
+30
View File
@@ -1,4 +1,5 @@
import { PluginQueryParams, SystemDependency } from '@lobehub/market-sdk';
import { z } from 'zod';
import { MCPErrorType } from '@/libs/mcp';
@@ -219,3 +220,32 @@ export interface McpConnectionParams {
}
export type MCPInstallProgressMap = Record<string, MCPInstallProgress | undefined>;
// ============ Zod Schemas ============
/**
* Zod schema for HTTP MCP authentication
*/
export const StreamableHTTPAuthSchema = z
.object({
accessToken: z.string().optional(), // OAuth2 Access Token
token: z.string().optional(), // Bearer Token
type: z.enum(['none', 'bearer', 'oauth2']),
})
.optional();
/**
* Zod schema for getStreamableMcpServerManifest input
*/
export const GetStreamableMcpServerManifestInputSchema = z.object({
auth: StreamableHTTPAuthSchema,
headers: z.record(z.string()).optional(),
identifier: z.string(),
metadata: z
.object({
avatar: z.string().optional(),
description: z.string().optional(),
})
.optional(),
url: z.string().url(),
});
+7
View File
@@ -57,3 +57,10 @@ export interface BuiltinPlaceholderProps {
}
export type BuiltinPlaceholder = (props: BuiltinPlaceholderProps) => ReactNode;
export interface BuiltinServerRuntimeOutput {
content: string;
error?: any;
state?: any;
success: boolean;
}
+10 -1
View File
@@ -1,3 +1,7 @@
import { CrawlUniformResult } from '@lobechat/web-crawler';
import { CrawlMultiPagesQuery } from '../crawler';
export interface SearchParams {
searchCategories?: string[];
searchEngines?: string[];
@@ -24,7 +28,7 @@ export interface UniformSearchResult {
content: string;
engines: string[];
/**
* 视频会用到
* Used for video results
*/
iframeSrc?: string;
imgSrc?: string;
@@ -42,3 +46,8 @@ export interface UniformSearchResponse {
resultNumbers: number;
results: UniformSearchResult[];
}
export interface SearchServiceImpl {
crawlPages(params: CrawlMultiPagesQuery): Promise<{ results: CrawlUniformResult[] }>;
webSearch(params: SearchQuery): Promise<UniformSearchResponse>;
}
@@ -267,7 +267,7 @@ describe('fetchSSE', () => {
});
});
it('should handle tool_calls event with smoothing correctly', async () => {
it('should handle tool_calls event correctly', async () => {
const mockOnMessageHandle = vi.fn();
const mockOnFinish = vi.fn();
@@ -300,25 +300,25 @@ describe('fetchSSE', () => {
responseAnimation: 'smooth',
});
// TODO: need to check whether the `aarg1` is correct
expect(mockOnMessageHandle).toHaveBeenNthCalledWith(1, {
isAnimationActives: [true, true],
tool_calls: [
{ id: '1', type: 'function', function: { name: 'func1', arguments: 'aarg1' } },
{ function: { arguments: 'aarg2', name: 'func2' }, id: '2', type: 'function' },
],
tool_calls: [{ id: '1', type: 'function', function: { name: 'func1', arguments: 'a' } }],
type: 'tool_calls',
});
expect(mockOnMessageHandle).toHaveBeenNthCalledWith(2, {
isAnimationActives: [true, true],
tool_calls: [
{ id: '1', type: 'function', function: { name: 'func1', arguments: 'aarg1' } },
{ id: '2', type: 'function', function: { name: 'func2', arguments: 'aarg2' } },
{ id: '1', type: 'function', function: { name: 'func1', arguments: 'arg1' } },
{ id: '2', type: 'function', function: { name: 'func2', arguments: 'a' } },
],
type: 'tool_calls',
});
expect(mockOnMessageHandle).toHaveBeenNthCalledWith(3, {
tool_calls: [
{ id: '1', type: 'function', function: { name: 'func1', arguments: 'arg1' } },
{ id: '2', type: 'function', function: { name: 'func2', arguments: 'arg2' } },
],
type: 'tool_calls',
});
// more assertions for each character...
expect(mockOnFinish).toHaveBeenCalledWith('', {
observationId: null,
toolCalls: [
+5 -138
View File
@@ -6,8 +6,6 @@ import {
ChatMessageError,
GroundingSearch,
MessageToolCall,
MessageToolCallChunk,
MessageToolCallSchema,
ModelReasoning,
ModelSpeed,
ModelUsage,
@@ -95,8 +93,6 @@ export interface FetchSSEOptions {
const START_ANIMATION_SPEED = 10; // 默认起始速度
const END_ANIMATION_SPEED = 16;
const createSmoothMessage = (params: {
onTextUpdate: (delta: string, text: string) => void;
startSpeed?: number;
@@ -200,112 +196,10 @@ const createSmoothMessage = (params: {
};
};
const createSmoothToolCalls = (params: {
onToolCallsUpdate: (toolCalls: MessageToolCall[], isAnimationActives: boolean[]) => void;
startSpeed?: number;
}) => {
const { startSpeed = START_ANIMATION_SPEED } = params;
let toolCallsBuffer: MessageToolCall[] = [];
// 为每个 tool_call 维护一个输出队列和动画控制器
const outputQueues: string[][] = [];
const isAnimationActives: boolean[] = [];
const animationFrameIds: (number | null)[] = [];
const stopAnimation = (index: number) => {
isAnimationActives[index] = false;
if (animationFrameIds[index] !== null) {
cancelAnimationFrame(animationFrameIds[index]!);
animationFrameIds[index] = null;
}
};
const startAnimation = (index: number, speed = startSpeed) =>
new Promise<void>((resolve) => {
if (isAnimationActives[index]) {
resolve();
return;
}
isAnimationActives[index] = true;
const updateToolCall = () => {
if (!isAnimationActives[index]) {
resolve();
return;
}
if (outputQueues[index].length > 0) {
const charsToAdd = outputQueues[index].splice(0, speed).join('');
const toolCallToUpdate = toolCallsBuffer[index];
if (toolCallToUpdate) {
toolCallToUpdate.function.arguments += charsToAdd;
// 触发 ui 更新
params.onToolCallsUpdate(toolCallsBuffer, [...isAnimationActives]);
}
animationFrameIds[index] = requestAnimationFrame(() => updateToolCall());
} else {
isAnimationActives[index] = false;
animationFrameIds[index] = null;
resolve();
}
};
animationFrameIds[index] = requestAnimationFrame(() => updateToolCall());
});
const pushToQueue = (toolCallChunks: MessageToolCallChunk[]) => {
toolCallChunks.forEach((chunk) => {
// init the tool call buffer and output queue
if (!toolCallsBuffer[chunk.index]) {
toolCallsBuffer[chunk.index] = MessageToolCallSchema.parse(chunk);
}
if (!outputQueues[chunk.index]) {
outputQueues[chunk.index] = [];
isAnimationActives[chunk.index] = false;
animationFrameIds[chunk.index] = null;
}
outputQueues[chunk.index].push(...(chunk.function?.arguments || '').split(''));
});
};
const startAnimations = async (speed = startSpeed) => {
const pools = toolCallsBuffer.map(async (_, index) => {
if (outputQueues[index].length > 0 && !isAnimationActives[index]) {
await startAnimation(index, speed);
}
});
await Promise.all(pools);
};
const stopAnimations = () => {
toolCallsBuffer.forEach((_, index) => {
stopAnimation(index);
});
};
return {
isAnimationActives,
isTokenRemain: () => outputQueues.some((token) => token.length > 0),
pushToQueue,
startAnimations,
stopAnimations,
};
};
export const standardizeAnimationStyle = (
animationStyle?: ResponseAnimation,
): Exclude<ResponseAnimation, ResponseAnimationStyle> => {
return typeof animationStyle === 'object'
? animationStyle
: { text: animationStyle, toolsCalling: animationStyle };
return typeof animationStyle === 'object' ? animationStyle : { text: animationStyle };
};
/**
@@ -319,14 +213,11 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
let finishedType: SSEFinishType = 'done';
let response!: Response;
const {
text,
toolsCalling,
speed: smoothingSpeed,
} = standardizeAnimationStyle(options.responseAnimation ?? {});
const { text, speed: smoothingSpeed } = standardizeAnimationStyle(
options.responseAnimation ?? {},
);
const shouldSkipTextProcessing = text === 'none';
const textSmoothing = text === 'smooth';
const toolsCallingSmoothing = toolsCalling === 'smooth';
// 添加文本buffer和计时器相关变量
let textBuffer = '';
@@ -371,13 +262,6 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
}
};
const toolCallsController = createSmoothToolCalls({
onToolCallsUpdate: (toolCalls, isAnimationActives) => {
options.onMessageHandle?.({ isAnimationActives, tool_calls: toolCalls, type: 'tool_calls' });
},
startSpeed: smoothingSpeed,
});
let grounding: GroundingSearch | undefined = undefined;
let usage: ModelUsage | undefined = undefined;
let images: ChatImageChunk[] = [];
@@ -531,19 +415,7 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
// if there is no tool calls, we should initialize the tool calls
if (!toolCalls) toolCalls = [];
toolCalls = parseToolCalls(toolCalls, data);
if (toolsCallingSmoothing) {
// make the tool calls smooth
// push the tool calls to the smooth queue
toolCallsController.pushToQueue(data);
// if there is no animation active, we should start the animation
if (toolCallsController.isAnimationActives.some((value) => !value)) {
toolCallsController.startAnimations();
}
} else {
options.onMessageHandle?.({ tool_calls: toolCalls, type: 'tool_calls' });
}
options.onMessageHandle?.({ tool_calls: toolCalls, type: 'tool_calls' });
}
}
},
@@ -561,7 +433,6 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
// so like abort, we don't need to call onFinish
if (response) {
textController.stopAnimation();
toolCallsController.stopAnimations();
// 确保所有缓冲区数据都被处理
if (bufferTimer) {
@@ -588,10 +459,6 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
await textController.startAnimation(smoothingSpeed);
}
if (toolCallsController.isTokenRemain()) {
await toolCallsController.startAnimations(END_ANIMATION_SPEED);
}
await options?.onFinish?.(output, {
grounding,
images: images.length > 0 ? images : undefined,
+119 -16
View File
@@ -1,7 +1,12 @@
import { describe, expect, it } from 'vitest';
import { pathString } from './url';
import { inferContentTypeFromImageUrl, inferFileExtensionFromImageUrl, isLocalUrl } from './url';
import {
inferContentTypeFromImageUrl,
inferFileExtensionFromImageUrl,
isLocalOrPrivateUrl,
isLocalUrl,
} from './url';
describe('pathString', () => {
it('should handle basic path', () => {
@@ -400,7 +405,7 @@ describe('inferFileExtensionFromImageUrl', () => {
});
describe('isLocalUrl', () => {
it('should return true for URLs with 127.0.0.1 as hostname', () => {
it('should return true for 127.0.0.1', () => {
expect(isLocalUrl('http://127.0.0.1')).toBe(true);
expect(isLocalUrl('https://127.0.0.1')).toBe(true);
expect(isLocalUrl('http://127.0.0.1:8080')).toBe(true);
@@ -408,15 +413,16 @@ describe('isLocalUrl', () => {
expect(isLocalUrl('https://127.0.0.1/path?query=1#hash')).toBe(true);
});
it('should return false for URLs with "localhost" as hostname', () => {
expect(isLocalUrl('http://localhost')).toBe(false);
expect(isLocalUrl('http://localhost:3000')).toBe(false);
it('should return false for other 127.x.x.x addresses', () => {
expect(isLocalUrl('http://127.0.0.2')).toBe(false);
expect(isLocalUrl('http://127.1.1.1')).toBe(false);
expect(isLocalUrl('http://127.255.255.255')).toBe(false);
});
it('should return false for other IP addresses', () => {
expect(isLocalUrl('http://192.168.1.1')).toBe(false);
expect(isLocalUrl('http://0.0.0.0')).toBe(false);
expect(isLocalUrl('http://127.0.0.2')).toBe(false);
it('should return false for localhost', () => {
expect(isLocalUrl('http://localhost')).toBe(false);
expect(isLocalUrl('http://localhost:3000')).toBe(false);
expect(isLocalUrl('https://localhost/api')).toBe(false);
});
it('should return false for domain names', () => {
@@ -427,15 +433,112 @@ describe('isLocalUrl', () => {
it('should return false for malformed URLs', () => {
expect(isLocalUrl('invalid-url')).toBe(false);
expect(isLocalUrl('http://')).toBe(false);
expect(isLocalUrl('a string but not a url')).toBe(false);
});
it('should return false for empty or nullish strings', () => {
expect(isLocalUrl('')).toBe(false);
});
});
it('should return false for relative URLs', () => {
expect(isLocalUrl('/path/to/file')).toBe(false);
expect(isLocalUrl('./relative/path')).toBe(false);
describe('isLocalOrPrivateUrl', () => {
describe('loopback addresses (127.0.0.0/8)', () => {
it('should return true for 127.0.0.1', () => {
expect(isLocalOrPrivateUrl('http://127.0.0.1')).toBe(true);
expect(isLocalOrPrivateUrl('https://127.0.0.1')).toBe(true);
expect(isLocalOrPrivateUrl('http://127.0.0.1:8080')).toBe(true);
expect(isLocalOrPrivateUrl('http://127.0.0.1/path/to/resource')).toBe(true);
expect(isLocalOrPrivateUrl('https://127.0.0.1/path?query=1#hash')).toBe(true);
});
it('should return true for other 127.x.x.x addresses', () => {
expect(isLocalOrPrivateUrl('http://127.0.0.2')).toBe(true);
expect(isLocalOrPrivateUrl('http://127.1.1.1')).toBe(true);
expect(isLocalOrPrivateUrl('http://127.255.255.255')).toBe(true);
});
});
describe('localhost', () => {
it('should return true for localhost', () => {
expect(isLocalOrPrivateUrl('http://localhost')).toBe(true);
expect(isLocalOrPrivateUrl('http://localhost:3000')).toBe(true);
expect(isLocalOrPrivateUrl('https://localhost/api')).toBe(true);
});
it('should return true for localhost subdomains', () => {
expect(isLocalOrPrivateUrl('http://app.localhost')).toBe(true);
expect(isLocalOrPrivateUrl('http://test.localhost:8080')).toBe(true);
});
});
describe('IPv6 loopback', () => {
it('should return true for ::1', () => {
expect(isLocalOrPrivateUrl('http://[::1]')).toBe(true);
expect(isLocalOrPrivateUrl('http://[::1]:8080')).toBe(true);
});
});
describe('special addresses', () => {
it('should return true for 0.0.0.0', () => {
expect(isLocalOrPrivateUrl('http://0.0.0.0')).toBe(true);
expect(isLocalOrPrivateUrl('http://0.0.0.0:3000')).toBe(true);
});
});
describe('private network addresses', () => {
it('should return true for 10.0.0.0/8 (10.x.x.x)', () => {
expect(isLocalOrPrivateUrl('http://10.0.0.1')).toBe(true);
expect(isLocalOrPrivateUrl('http://10.1.2.3')).toBe(true);
expect(isLocalOrPrivateUrl('http://10.255.255.255')).toBe(true);
});
it('should return true for 172.16.0.0/12 (172.16-31.x.x)', () => {
expect(isLocalOrPrivateUrl('http://172.16.0.1')).toBe(true);
expect(isLocalOrPrivateUrl('http://172.20.10.5')).toBe(true);
expect(isLocalOrPrivateUrl('http://172.31.255.255')).toBe(true);
});
it('should return false for 172.x.x.x outside private range', () => {
expect(isLocalOrPrivateUrl('http://172.15.0.1')).toBe(false);
expect(isLocalOrPrivateUrl('http://172.32.0.1')).toBe(false);
});
it('should return true for 192.168.0.0/16 (192.168.x.x)', () => {
expect(isLocalOrPrivateUrl('http://192.168.1.1')).toBe(true);
expect(isLocalOrPrivateUrl('http://192.168.0.1')).toBe(true);
expect(isLocalOrPrivateUrl('http://192.168.255.255')).toBe(true);
});
});
describe('public addresses', () => {
it('should return false for public IP addresses', () => {
expect(isLocalOrPrivateUrl('http://8.8.8.8')).toBe(false);
expect(isLocalOrPrivateUrl('http://1.1.1.1')).toBe(false);
expect(isLocalOrPrivateUrl('http://192.167.1.1')).toBe(false);
expect(isLocalOrPrivateUrl('http://192.169.1.1')).toBe(false);
});
it('should return false for domain names', () => {
expect(isLocalOrPrivateUrl('https://example.com')).toBe(false);
expect(isLocalOrPrivateUrl('http://www.google.com')).toBe(false);
});
});
describe('edge cases', () => {
it('should return false for malformed URLs', () => {
expect(isLocalOrPrivateUrl('invalid-url')).toBe(false);
expect(isLocalOrPrivateUrl('http://')).toBe(false);
expect(isLocalOrPrivateUrl('a string but not a url')).toBe(false);
});
it('should return false for empty or nullish strings', () => {
expect(isLocalOrPrivateUrl('')).toBe(false);
});
it('should return false for relative URLs', () => {
expect(isLocalOrPrivateUrl('/path/to/file')).toBe(false);
expect(isLocalOrPrivateUrl('./relative/path')).toBe(false);
});
it('should return false for invalid IP addresses', () => {
expect(isLocalOrPrivateUrl('http://256.256.256.256')).toBe(false);
expect(isLocalOrPrivateUrl('http://192.168.1.256')).toBe(false);
});
});
});
+86
View File
@@ -151,3 +151,89 @@ export function isLocalUrl(url: string) {
return false;
}
}
/**
* Check if a URL points to localhost or private network address
*
* This function determines if the provided URL's hostname is a local or private network address.
* It checks for:
* - localhost (with or without domain suffix)
* - 127.0.0.0/8 (loopback addresses)
* - ::1 (IPv6 loopback)
* - 0.0.0.0
* - 10.0.0.0/8 (private network)
* - 172.16.0.0/12 (private network)
* - 192.168.0.0/16 (private network)
*
* It handles malformed URLs gracefully by returning false instead of throwing errors.
*
* @param url - The URL string to check
* @returns true if the URL points to a local or private network address, false otherwise
*
* @example
* ```typescript
* isLocalOrPrivateUrl('http://127.0.0.1:8080/path') // true
* isLocalOrPrivateUrl('http://localhost:3000') // true
* isLocalOrPrivateUrl('http://192.168.1.1') // true
* isLocalOrPrivateUrl('http://10.0.0.1') // true
* isLocalOrPrivateUrl('https://example.com') // false
* isLocalOrPrivateUrl('invalid-url') // false (instead of throwing)
* isLocalOrPrivateUrl('') // false (instead of throwing)
* ```
*/
export function isLocalOrPrivateUrl(url: string) {
try {
const hostname = new URL(url).hostname.toLowerCase();
// Check for localhost variants
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
return true;
}
// Check for IPv6 loopback
if (hostname === '::1' || hostname === '[::1]') {
return true;
}
// Check for 0.0.0.0
if (hostname === '0.0.0.0') {
return true;
}
// Check for IPv4 loopback and private networks
const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipv4Match) {
const [, a, b, c, d] = ipv4Match.map(Number);
// Validate that all octets are in valid range (0-255)
if (a > 255 || b > 255 || c > 255 || d > 255) {
return false;
}
// 127.0.0.0/8 - Loopback
if (a === 127) {
return true;
}
// 10.0.0.0/8 - Private network
if (a === 10) {
return true;
}
// 172.16.0.0/12 - Private network
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
// 192.168.0.0/16 - Private network
if (a === 192 && b === 168) {
return true;
}
}
return false;
} catch {
// Return false for malformed URLs instead of throwing
return false;
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { CrawlImplType, crawlImpls } from './crawImpl';
import { CrawlUrlRule } from './type';
import { CrawlUniformResult, CrawlUrlRule } from './type';
import { crawUrlRules } from './urlRules';
import { applyUrlRules } from './utils/appUrlRules';
@@ -30,7 +30,7 @@ export class Crawler {
filterOptions?: CrawlUrlRule['filterOptions'];
impls?: CrawlImplType[];
url: string;
}) {
}): Promise<CrawlUniformResult> {
// 应用URL规则
const {
transformedUrl,
@@ -76,7 +76,7 @@ export class Crawler {
const errorMessage = finalError?.message;
return {
crawler: finalCrawler,
crawler: finalCrawler!,
data: {
content: `Fail to crawl the page. Error type: ${errorType}, error message: ${errorMessage}`,
errorMessage: errorMessage,
+10 -3
View File
@@ -10,9 +10,9 @@ export interface CrawlSuccessResult {
export interface CrawlErrorResult {
content: string;
errorMessage: string;
errorType: string;
url: string;
errorMessage?: string;
errorType?: string;
url?: string;
}
export interface FilterOptions {
@@ -42,3 +42,10 @@ export interface CrawlUrlRule {
// URL转换模板(可选),如果提供则进行URL转换
urlTransform?: string;
}
export interface CrawlUniformResult {
crawler: string;
data: CrawlSuccessResult | CrawlErrorResult;
originalUrl: string;
transformedUrl?: string;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { LobeTool } from '@lobechat/types';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { BaseModel } from '@/database/_deprecated/core';
import { LobeTool } from '@/types/tool';
import { merge } from '@/utils/merge';
import { DB_Plugin, DB_PluginSchema } from '../schemas/plugin';
@@ -12,9 +12,11 @@ const PluginState = memo<FunctionMessageProps>(({ toolCallId }) => {
const toolMessage = useChatStore(chatSelectors.getMessageByToolCallId(toolCallId));
return (
<Highlighter language={'json'} style={{ maxHeight: 200, maxWidth: 800, overflow: 'scroll' }}>
{JSON.stringify(toolMessage?.pluginState, null, 2)}
</Highlighter>
toolMessage?.pluginState && (
<Highlighter language={'json'} style={{ maxHeight: 200, maxWidth: 800, overflow: 'scroll' }}>
{JSON.stringify(toolMessage?.pluginState, null, 2)}
</Highlighter>
)
);
});
@@ -194,7 +194,7 @@ const AssistantMessage = memo<AssistantMessageProps>((props) => {
direction={'vertical'}
gap={8}
>
<Flexbox width={'100%'}>
<Flexbox style={{ flex: 1, maxWidth: '100%' }}>
{error && (message === LOADING_FLAT || !message) ? (
<ErrorContent error={errorContent} message={errorMessage} placement={placement} />
) : (
+1 -2
View File
@@ -1,8 +1,7 @@
import { LobeToolRenderType } from '@lobechat/types';
import { PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { memo } from 'react';
import { LobeToolRenderType } from '@/types/tool';
import BuiltinType from './BuiltinType';
import DefaultType from './DefaultType';
import Markdown from './MarkdownType';
+6 -1
View File
@@ -317,7 +317,12 @@ export class MCPClient {
log('Listed tools: %O', tools);
return tools as McpTool[];
} catch (e) {
log('Listed tools error: %O', e);
console.error('Listed tools error: %O', e);
if ((e as Error).message.includes('No valid session ID provided')) {
throw new Error('NoValidSessionId');
}
return [];
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { LobeToolRenderType } from '@/types/tool';
import { LobeToolRenderType } from '@lobechat/types';
export interface V4ChatPluginPayload {
apiName: string;
+1 -1
View File
@@ -1,4 +1,4 @@
import { LobeToolRenderType } from '@/types/tool';
import { LobeToolRenderType } from '@lobechat/types';
import { V4ChatPluginPayload } from './v4';
+17
View File
@@ -1,3 +1,4 @@
import { GetStreamableMcpServerManifestInputSchema } from '@lobechat/types';
import debug from 'debug';
import { z } from 'zod';
@@ -34,6 +35,22 @@ export const mcpRouter = router({
return await mcpService.getStdioMcpServerManifest(input, input.metadata);
}),
getStreamableMcpServerManifest: mcpProcedure
.input(GetStreamableMcpServerManifestInputSchema)
.query(async ({ input }) => {
log('getStreamableMcpServerManifest input: %O', {
identifier: input.identifier,
url: input.url,
});
return await mcpService.getStreamableMcpServerManifest(
input.identifier,
input.url,
input.metadata,
input.auth,
input.headers,
);
}),
/* eslint-disable sort-keys-fix/sort-keys-fix */
// --- MCP Interaction ---
// listTools now accepts MCPClientParams directly
+1 -1
View File
@@ -1,10 +1,10 @@
import { LobeTool } from '@lobechat/types';
import { z } from 'zod';
import { PluginModel } from '@/database/models/plugin';
import { getServerDB } from '@/database/server';
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { LobeTool } from '@/types/tool';
const pluginProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
+5 -23
View File
@@ -1,3 +1,7 @@
import {
GetStreamableMcpServerManifestInputSchema,
StreamableHTTPAuthSchema,
} from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
@@ -6,15 +10,6 @@ import { passwordProcedure } from '@/libs/trpc/edge';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { mcpService } from '@/server/services/mcp';
const StreamableHTTPAuthSchema = z
.object({
// Bearer Token
accessToken: z.string().optional(),
token: z.string().optional(),
type: z.enum(['none', 'bearer', 'oauth2']), // OAuth2 Access Token
})
.optional();
// Define Zod schemas for MCP Client parameters
const httpParamsSchema = z.object({
auth: StreamableHTTPAuthSchema,
@@ -47,20 +42,7 @@ const mcpProcedure = isServerMode ? authedProcedure : passwordProcedure;
export const mcpRouter = router({
getStreamableMcpServerManifest: mcpProcedure
.input(
z.object({
auth: StreamableHTTPAuthSchema,
headers: z.record(z.string()).optional(),
identifier: z.string(),
metadata: z
.object({
avatar: z.string().optional(),
description: z.string().optional(),
})
.optional(),
url: z.string().url(),
}),
)
.input(GetStreamableMcpServerManifestInputSchema)
.query(async ({ input }) => {
return await mcpService.getStreamableMcpServerManifest(
input.identifier,
+1 -1
View File
@@ -1,10 +1,10 @@
// @vitest-environment node
import { SEARCH_SEARXNG_NOT_CONFIG } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toolsEnv } from '@/envs/tools';
import { SearXNGClient } from '@/server/services/search/impls/searxng/client';
import { SEARCH_SEARXNG_NOT_CONFIG } from '@/types/tool/search';
import { searchRouter } from './search';
+14 -1
View File
@@ -1,6 +1,6 @@
import { isServerMode } from '@lobechat/const';
import { z } from 'zod';
import { isServerMode } from '@/const/version';
import { passwordProcedure } from '@/libs/trpc/edge';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { searchService } from '@/server/services/search';
@@ -36,4 +36,17 @@ export const searchRouter = router({
.query(async ({ input }) => {
return await searchService.query(input.query, input.optionalParams);
}),
webSearch: searchProcedure
.input(
z.object({
query: z.string(),
searchCategories: z.array(z.string()).optional(),
searchEngines: z.array(z.string()).optional(),
searchTimeRange: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await searchService.webSearch(input);
}),
});
+57 -49
View File
@@ -23,58 +23,66 @@ vi.mock('@/locales/resources', () => ({
process.env.MARKET_BASE_URL = 'http://localhost:8787/api';
// Mock constants with inline data
vi.mock('model-bank', () => ({
LOBE_DEFAULT_MODEL_LIST: [
{
id: 'gpt-4',
displayName: 'GPT-4',
description: 'OpenAI GPT-4 model',
providerId: 'openai',
contextWindowTokens: 8192,
abilities: {
vision: true,
functionCall: true,
files: true,
vi.mock('model-bank', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as any),
LOBE_DEFAULT_MODEL_LIST: [
{
id: 'gpt-4',
displayName: 'GPT-4',
description: 'OpenAI GPT-4 model',
providerId: 'openai',
contextWindowTokens: 8192,
abilities: {
vision: true,
functionCall: true,
files: true,
},
pricing: {
input: 0.03,
output: 0.06,
},
releasedAt: '2023-03-01T00:00:00Z',
},
pricing: {
input: 0.03,
output: 0.06,
{
id: 'claude-3-opus',
displayName: 'Claude 3 Opus',
description: 'Anthropic Claude 3 Opus model',
providerId: 'anthropic',
contextWindowTokens: 200000,
abilities: {
vision: true,
reasoning: true,
},
pricing: {
input: 0.015,
output: 0.075,
},
releasedAt: '2024-02-01T00:00:00Z',
},
releasedAt: '2023-03-01T00:00:00Z',
},
{
id: 'claude-3-opus',
displayName: 'Claude 3 Opus',
description: 'Anthropic Claude 3 Opus model',
providerId: 'anthropic',
contextWindowTokens: 200000,
abilities: {
vision: true,
reasoning: true,
},
pricing: {
input: 0.015,
output: 0.075,
},
releasedAt: '2024-02-01T00:00:00Z',
},
],
}));
],
};
});
vi.mock('@/config/modelProviders', () => ({
DEFAULT_MODEL_PROVIDER_LIST: [
{
id: 'openai',
name: 'OpenAI',
description: 'OpenAI provider',
},
{
id: 'anthropic',
name: 'Anthropic',
description: 'Anthropic provider',
},
],
}));
vi.mock('@/config/modelProviders', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as any),
DEFAULT_MODEL_PROVIDER_LIST: [
{
id: 'openai',
name: 'OpenAI',
description: 'OpenAI provider',
},
{
id: 'anthropic',
name: 'Anthropic',
description: 'Anthropic provider',
},
],
};
});
vi.mock('@/const/discover', () => ({
DEFAULT_DISCOVER_ASSISTANT_ITEM: {},
+16 -15
View File
@@ -1,20 +1,10 @@
import { CategoryItem, CategoryListQuery, MarketSDK } from '@lobehub/market-sdk';
import { CallReportRequest, InstallReportRequest } from '@lobehub/market-types';
import dayjs from 'dayjs';
import debug from 'debug';
import matter from 'gray-matter';
import { cloneDeep, countBy, isString, merge, uniq, uniqBy } from 'lodash-es';
import urlJoin from 'url-join';
import {
CURRENT_VERSION,
DEFAULT_DISCOVER_ASSISTANT_ITEM,
DEFAULT_DISCOVER_PLUGIN_ITEM,
DEFAULT_DISCOVER_PROVIDER_ITEM,
} from '@/const/discover';
import { CURRENT_VERSION, isDesktop } from '@/const/version';
import { normalizeLocale } from '@/locales/resources';
import { AssistantStore } from '@/server/modules/AssistantStore';
import { PluginStore } from '@/server/modules/PluginStore';
isDesktop,
} from '@lobechat/const';
import {
AssistantListResponse,
AssistantQueryParams,
@@ -42,12 +32,23 @@ import {
ProviderListResponse,
ProviderQueryParams,
ProviderSorts,
} from '@/types/discover';
} from '@lobechat/types';
import {
getAudioInputUnitRate,
getTextInputUnitRate,
getTextOutputUnitRate,
} from '@/utils/pricing';
} from '@lobechat/utils';
import { CategoryItem, CategoryListQuery, MarketSDK } from '@lobehub/market-sdk';
import { CallReportRequest, InstallReportRequest } from '@lobehub/market-types';
import dayjs from 'dayjs';
import debug from 'debug';
import matter from 'gray-matter';
import { cloneDeep, countBy, isString, merge, uniq, uniqBy } from 'lodash-es';
import urlJoin from 'url-join';
import { normalizeLocale } from '@/locales/resources';
import { AssistantStore } from '@/server/modules/AssistantStore';
import { PluginStore } from '@/server/modules/PluginStore';
const log = debug('lobe-server:discover');
+154
View File
@@ -18,6 +18,7 @@ describe('MCPService', () => {
// 创建 mock 客户端
mockClient = {
callTool: vi.fn(),
listTools: vi.fn(),
};
// Mock getClient 方法返回 mock 客户端
@@ -158,4 +159,157 @@ describe('MCPService', () => {
expect(mockClient.callTool).toHaveBeenCalledWith('testTool', argsObject);
});
});
describe('listTools', () => {
const mockParams = {
name: 'test-mcp',
type: 'stdio' as const,
command: 'test-command',
args: ['--test'],
};
it('should successfully list tools and transform to LobeChatPluginApi format', async () => {
const mockTools = [
{
name: 'tool1',
description: 'First test tool',
inputSchema: {
type: 'object',
properties: { param1: { type: 'string' } },
},
},
{
name: 'tool2',
description: 'Second test tool',
inputSchema: {
type: 'object',
properties: { param2: { type: 'number' } },
},
},
];
mockClient.listTools.mockResolvedValue(mockTools);
const result = await mcpService.listTools(mockParams);
expect(mockClient.listTools).toHaveBeenCalled();
expect(result).toEqual([
{
name: 'tool1',
description: 'First test tool',
parameters: {
type: 'object',
properties: { param1: { type: 'string' } },
},
},
{
name: 'tool2',
description: 'Second test tool',
parameters: {
type: 'object',
properties: { param2: { type: 'number' } },
},
},
]);
});
it('should return empty array when no tools available', async () => {
mockClient.listTools.mockResolvedValue([]);
const result = await mcpService.listTools(mockParams);
expect(result).toEqual([]);
});
it('should retry with skipCache when NoValidSessionId error occurs (first retry)', async () => {
const mockTools = [
{
name: 'tool1',
description: 'Test tool',
inputSchema: { type: 'object' },
},
];
// First call fails with NoValidSessionId
mockClient.listTools.mockRejectedValueOnce(new Error('NoValidSessionId'));
// Second call (with skipCache=true) succeeds
mockClient.listTools.mockResolvedValueOnce(mockTools);
const result = await mcpService.listTools(mockParams);
expect(mockClient.listTools).toHaveBeenCalledTimes(2);
expect(result).toEqual([
{
name: 'tool1',
description: 'Test tool',
parameters: { type: 'object' },
},
]);
});
it('should retry up to 3 times for NoValidSessionId error', async () => {
const mockTools = [
{
name: 'tool1',
description: 'Test tool',
inputSchema: { type: 'object' },
},
];
// Fail 3 times, succeed on 4th
mockClient.listTools
.mockRejectedValueOnce(new Error('NoValidSessionId'))
.mockRejectedValueOnce(new Error('NoValidSessionId'))
.mockRejectedValueOnce(new Error('NoValidSessionId'))
.mockResolvedValueOnce(mockTools);
const result = await mcpService.listTools(mockParams);
expect(mockClient.listTools).toHaveBeenCalledTimes(4);
expect(result).toHaveLength(1);
});
it('should throw TRPCError when NoValidSessionId retry exceeds limit', async () => {
// Fail more than 3 times
mockClient.listTools.mockRejectedValue(new Error('NoValidSessionId'));
await expect(mcpService.listTools(mockParams)).rejects.toThrow(TRPCError);
expect(mockClient.listTools).toHaveBeenCalledTimes(5); // initial + 4 retry attempts (last one fails condition)
});
it('should throw TRPCError on other errors without retry', async () => {
const error = new Error('Connection failed');
mockClient.listTools.mockRejectedValue(error);
await expect(mcpService.listTools(mockParams)).rejects.toThrow(TRPCError);
expect(mockClient.listTools).toHaveBeenCalledTimes(1);
});
it('should pass skipCache option to getClient', async () => {
const mockTools = [
{
name: 'tool1',
description: 'Test tool',
inputSchema: { type: 'object' },
},
];
mockClient.listTools.mockResolvedValue(mockTools);
await mcpService.listTools(mockParams, { skipCache: true });
// Verify getClient was called with skipCache
expect(mcpService.getClient).toHaveBeenCalledWith(mockParams, true);
});
it('should throw TRPCError with correct error message', async () => {
const error = new Error('Custom error message');
mockClient.listTools.mockRejectedValue(error);
await expect(mcpService.listTools(mockParams)).rejects.toMatchObject({
message: 'Error listing tools from MCP server: Custom error message',
code: 'INTERNAL_SERVER_ERROR',
});
});
});
});
+24 -9
View File
@@ -1,3 +1,5 @@
import { CheckMcpInstallResult, CustomPluginMetadata } from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import { LobeChatPluginApi, LobeChatPluginManifest, PluginSchema } from '@lobehub/chat-plugin-sdk';
import { DeploymentOption } from '@lobehub/market-sdk';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
@@ -12,16 +14,14 @@ import {
McpTool,
StdioMCPParams,
} from '@/libs/mcp';
import { mcpSystemDepsCheckService } from '@/server/services/mcp/deps';
import { CheckMcpInstallResult } from '@/types/plugins';
import { CustomPluginMetadata } from '@/types/tool/plugin';
import { safeParseJSON } from '@/utils/safeParseJSON';
import { mcpSystemDepsCheckService } from './deps';
const log = debug('lobe-mcp:service');
// Removed MCPConnection interface as it's no longer needed
class MCPService {
export class MCPService {
// Store instances of the custom MCPClient, keyed by serialized MCPClientParams
private clients: Map<string, MCPClient> = new Map();
@@ -35,8 +35,11 @@ class MCPService {
// --- MCP Interaction ---
// listTools now accepts MCPClientParams
async listTools(params: MCPClientParams): Promise<LobeChatPluginApi[]> {
const client = await this.getClient(params); // Get client using params
async listTools(
params: MCPClientParams,
{ retryTime, skipCache }: { retryTime?: number; skipCache?: boolean } = {},
): Promise<LobeChatPluginApi[]> {
const client = await this.getClient(params, skipCache); // Get client using params
const loggableParams = this.sanitizeForLogging(params);
log(`Listing tools using client for params: %O`, loggableParams);
@@ -54,6 +57,18 @@ class MCPService {
parameters: item.inputSchema as PluginSchema,
}));
} catch (error) {
let nextReTryTime = retryTime || 0;
if ((error as Error).message === 'NoValidSessionId' && nextReTryTime <= 3) {
if (!nextReTryTime) {
nextReTryTime = 1;
} else {
nextReTryTime += 1;
}
return this.listTools(params, { retryTime: nextReTryTime, skipCache: true });
}
console.error(`Error listing tools for params %O:`, loggableParams, error);
// Propagate a TRPCError for better handling upstream
throw new TRPCError({
@@ -200,10 +215,10 @@ class MCPService {
}
// Private method to get or initialize a client based on parameters
private async getClient(params: MCPClientParams): Promise<MCPClient> {
private async getClient(params: MCPClientParams, skipCache = false): Promise<MCPClient> {
const key = this.serializeParams(params); // Use custom serialization
if (this.clients.has(key)) {
if (!skipCache && this.clients.has(key)) {
return this.clients.get(key)!;
}
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { AnspireSearchParameters, AnspireResponse } from './type';
import { AnspireResponse, AnspireSearchParameters } from './type';
const log = debug('lobe-search:Anspire');
@@ -43,7 +42,10 @@ export class AnspireImpl implements SearchServiceImpl {
if (days === undefined) return {};
return {
FromTime: new Date(now - days * 86_400 * 1000).toISOString().slice(0, 19).replace('T', ' '),
FromTime: new Date(now - days * 86_400 * 1000)
.toISOString()
.slice(0, 19)
.replace('T', ' '),
ToTime: new Date(now).toISOString().slice(0, 19).replace('T', ' '),
};
})()
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { BochaSearchParameters, BochaResponse } from './type';
import { BochaResponse, BochaSearchParameters } from './type';
const log = debug('lobe-search:Bocha');
@@ -44,7 +43,7 @@ export class BochaImpl implements SearchServiceImpl {
...defaultQueryParams,
freshness:
params?.searchTimeRange && params.searchTimeRange !== 'anytime'
? timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined
? (timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined)
: undefined,
};
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { BraveSearchParameters, BraveResponse } from './type';
import { BraveResponse, BraveSearchParameters } from './type';
const log = debug('lobe-search:Brave');
@@ -44,7 +43,7 @@ export class BraveImpl implements SearchServiceImpl {
...defaultQueryParams,
freshness:
params?.searchTimeRange && params.searchTimeRange !== 'anytime'
? timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined
? (timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined)
: undefined,
};
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { ExaSearchParameters, ExaResponse } from './type';
import { ExaResponse, ExaSearchParameters } from './type';
const log = debug('lobe-search:Exa');
@@ -48,9 +47,8 @@ export class ExaImpl implements SearchServiceImpl {
};
})()
: {}),
category:
// Exa 只支持 news 类型
params?.searchCategories?.filter(cat => ['news'].includes(cat))?.[0],
category: // Exa 只支持 news 类型
params?.searchCategories?.filter((cat) => ['news'].includes(cat))?.[0],
};
log('Constructed request body: %o', body);
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { FirecrawlSearchParameters, FirecrawlResponse } from './type';
import { FirecrawlResponse, FirecrawlSearchParameters } from './type';
const log = debug('lobe-search:Firecrawl');
@@ -48,7 +47,7 @@ export class FirecrawlImpl implements SearchServiceImpl {
...defaultQueryParams,
tbs:
params?.searchTimeRange && params.searchTimeRange !== 'anytime'
? timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined
? (timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined)
: undefined,
};
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { GoogleSearchParameters, GoogleResponse } from './type';
import { GoogleResponse, GoogleSearchParameters } from './type';
const log = debug('lobe-search:Google');
@@ -49,7 +48,7 @@ export class GoogleImpl implements SearchServiceImpl {
...defaultQueryParams,
dateRestrict:
params?.searchTimeRange && params.searchTimeRange !== 'anytime'
? timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined
? (timeRangeMapping[params.searchTimeRange as keyof typeof timeRangeMapping] ?? undefined)
: undefined,
};
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { JinaSearchParameters, JinaResponse } from './type';
import { JinaResponse, JinaSearchParameters } from './type';
const log = debug('lobe-search:Jina');
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { KagiSearchParameters, KagiResponse } from './type';
import { KagiResponse, KagiSearchParameters } from './type';
const log = debug('lobe-search:Kagi');
@@ -46,7 +45,7 @@ export class KagiImpl implements SearchServiceImpl {
log('Sending request to endpoint: %s', endpoint);
response = await fetch(`${endpoint}?${searchParams.toString()}`, {
headers: {
'Authorization': this.apiKey ? `Bot ${this.apiKey}` : '',
Authorization: this.apiKey ? `Bot ${this.apiKey}` : '',
},
method: 'GET',
});
@@ -1,9 +1,8 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { Search1ApiResponse } from './type';
@@ -1,8 +1,8 @@
import { SEARCH_SEARXNG_NOT_CONFIG, UniformSearchResponse } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { toolsEnv } from '@/envs/tools';
import { SearXNGClient } from '@/server/services/search/impls/searxng/client';
import { SEARCH_SEARXNG_NOT_CONFIG, UniformSearchResponse } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
@@ -1,11 +1,10 @@
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import urlJoin from 'url-join';
import { SearchParams, UniformSearchResponse, UniformSearchResult } from '@/types/tool/search';
import { SearchServiceImpl } from '../type';
import { TavilySearchParameters, TavilyResponse } from './type';
import { TavilyResponse, TavilySearchParameters } from './type';
const log = debug('lobe-search:Tavily');
@@ -34,7 +33,7 @@ export class TavilyImpl implements SearchServiceImpl {
include_raw_content: false,
max_results: 15,
query,
search_depth: process.env.TAVILY_SEARCH_DEPTH || 'basic' // basic or advanced
search_depth: process.env.TAVILY_SEARCH_DEPTH || 'basic', // basic or advanced
};
let body: TavilySearchParameters = {
@@ -43,9 +42,8 @@ export class TavilyImpl implements SearchServiceImpl {
params?.searchTimeRange && params.searchTimeRange !== 'anytime'
? params.searchTimeRange
: undefined,
topic:
// Tavily 只支持 news 和 general 两种类型
params?.searchCategories?.filter(cat => ['news', 'general'].includes(cat))?.[0],
topic: // Tavily 只支持 news 和 general 两种类型
params?.searchCategories?.filter((cat) => ['news', 'general'].includes(cat))?.[0],
};
log('Constructed request body: %o', body);
+1 -1
View File
@@ -1,4 +1,4 @@
import { SearchParams, UniformSearchResponse } from '@/types/tool/search';
import { SearchParams, UniformSearchResponse } from '@lobechat/types';
/**
* Search service implementation interface
+338
View File
@@ -0,0 +1,338 @@
import { Crawler } from '@lobechat/web-crawler';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toolsEnv } from '@/envs/tools';
import { SearchImplType, createSearchServiceImpl } from './impls';
import { SearchService } from './index';
// Mock dependencies
vi.mock('@lobechat/web-crawler');
vi.mock('./impls');
vi.mock('@/envs/tools', () => ({
toolsEnv: {
CRAWLER_IMPLS: '',
SEARCH_PROVIDERS: '',
},
}));
describe('SearchService', () => {
let searchService: SearchService;
let mockSearchImpl: ReturnType<typeof createMockSearchImpl>;
function createMockSearchImpl() {
return {
query: vi.fn(),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockSearchImpl = createMockSearchImpl();
vi.mocked(createSearchServiceImpl).mockReturnValue(mockSearchImpl as any);
searchService = new SearchService();
});
describe('constructor', () => {
it('should create instance with default search implementation when no providers configured', () => {
expect(createSearchServiceImpl).toHaveBeenCalledWith(undefined);
});
it('should create instance with first provider from SEARCH_PROVIDERS', () => {
vi.mocked(toolsEnv).SEARCH_PROVIDERS = 'tavily,brave';
searchService = new SearchService();
expect(createSearchServiceImpl).toHaveBeenCalledWith(SearchImplType.Tavily);
});
it('should handle full-width comma in SEARCH_PROVIDERS', () => {
vi.mocked(toolsEnv).SEARCH_PROVIDERS = 'tavilybrave';
searchService = new SearchService();
expect(createSearchServiceImpl).toHaveBeenCalledWith(SearchImplType.Tavily);
});
it('should trim whitespace in SEARCH_PROVIDERS', () => {
vi.mocked(toolsEnv).SEARCH_PROVIDERS = ' tavily , brave ';
searchService = new SearchService();
expect(createSearchServiceImpl).toHaveBeenCalledWith(SearchImplType.Tavily);
});
});
describe('query', () => {
it('should call searchImpl.query with correct parameters', async () => {
const mockResponse = {
costTime: 100,
query: 'test query',
resultNumbers: 1,
results: [],
};
mockSearchImpl.query.mockResolvedValue(mockResponse);
const result = await searchService.query('test query');
expect(mockSearchImpl.query).toHaveBeenCalledWith('test query', undefined);
expect(result).toBe(mockResponse);
});
it('should pass search parameters to searchImpl.query', async () => {
const mockResponse = {
costTime: 100,
query: 'test query',
resultNumbers: 1,
results: [],
};
mockSearchImpl.query.mockResolvedValue(mockResponse);
const params = {
searchCategories: ['general'],
searchEngines: ['google'],
searchTimeRange: '1d',
};
await searchService.query('test query', params);
expect(mockSearchImpl.query).toHaveBeenCalledWith('test query', params);
});
});
describe('webSearch', () => {
it('should return results on first attempt if results found', async () => {
const mockResponse = {
costTime: 100,
query: 'test',
resultNumbers: 2,
results: [
{
category: 'general',
content: 'Result 1',
engines: ['google'],
parsedUrl: 'https://example.com',
score: 1,
title: 'Test 1',
url: 'https://example.com',
},
],
};
mockSearchImpl.query.mockResolvedValue(mockResponse);
const result = await searchService.webSearch({
query: 'test',
searchCategories: ['general'],
searchEngines: ['google'],
});
expect(mockSearchImpl.query).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it('should retry without searchEngines when no results found', async () => {
const emptyResponse = {
costTime: 100,
query: 'test',
resultNumbers: 0,
results: [],
};
const successResponse = {
costTime: 100,
query: 'test',
resultNumbers: 1,
results: [
{
category: 'general',
content: 'Result 1',
engines: ['google'],
parsedUrl: 'https://example.com',
score: 1,
title: 'Test 1',
url: 'https://example.com',
},
],
};
mockSearchImpl.query
.mockResolvedValueOnce(emptyResponse)
.mockResolvedValueOnce(successResponse);
const result = await searchService.webSearch({
query: 'test',
searchCategories: ['general'],
searchEngines: ['google'],
searchTimeRange: '1d',
});
expect(mockSearchImpl.query).toHaveBeenCalledTimes(2);
expect(mockSearchImpl.query).toHaveBeenNthCalledWith(1, 'test', {
searchCategories: ['general'],
searchEngines: ['google'],
searchTimeRange: '1d',
});
expect(mockSearchImpl.query).toHaveBeenNthCalledWith(2, 'test', {
searchCategories: ['general'],
searchEngines: undefined,
searchTimeRange: '1d',
});
expect(result).toBe(successResponse);
});
it('should retry without any params when still no results found', async () => {
const emptyResponse = {
costTime: 100,
query: 'test',
resultNumbers: 0,
results: [],
};
const successResponse = {
costTime: 100,
query: 'test',
resultNumbers: 1,
results: [
{
category: 'general',
content: 'Result 1',
engines: ['google'],
parsedUrl: 'https://example.com',
score: 1,
title: 'Test 1',
url: 'https://example.com',
},
],
};
mockSearchImpl.query
.mockResolvedValueOnce(emptyResponse)
.mockResolvedValueOnce(emptyResponse)
.mockResolvedValueOnce(successResponse);
const result = await searchService.webSearch({
query: 'test',
searchCategories: ['general'],
searchEngines: ['google'],
searchTimeRange: '1d',
});
expect(mockSearchImpl.query).toHaveBeenCalledTimes(3);
expect(mockSearchImpl.query).toHaveBeenNthCalledWith(3, 'test', undefined);
expect(result).toBe(successResponse);
});
it('should skip second retry if searchEngines not provided', async () => {
const emptyResponse = {
costTime: 100,
query: 'test',
resultNumbers: 0,
results: [],
};
const successResponse = {
costTime: 100,
query: 'test',
resultNumbers: 1,
results: [
{
category: 'general',
content: 'Result 1',
engines: ['google'],
parsedUrl: 'https://example.com',
score: 1,
title: 'Test 1',
url: 'https://example.com',
},
],
};
mockSearchImpl.query
.mockResolvedValueOnce(emptyResponse)
.mockResolvedValueOnce(successResponse);
const result = await searchService.webSearch({
query: 'test',
searchCategories: ['general'],
});
expect(mockSearchImpl.query).toHaveBeenCalledTimes(2);
expect(mockSearchImpl.query).toHaveBeenNthCalledWith(1, 'test', {
searchCategories: ['general'],
searchEngines: undefined,
searchTimeRange: undefined,
});
expect(mockSearchImpl.query).toHaveBeenNthCalledWith(2, 'test', undefined);
expect(result).toBe(successResponse);
});
it('should return empty results after all retries fail', async () => {
const emptyResponse = {
costTime: 100,
query: 'test',
resultNumbers: 0,
results: [],
};
mockSearchImpl.query.mockResolvedValue(emptyResponse);
const result = await searchService.webSearch({
query: 'test',
searchEngines: ['google'],
});
expect(result.results).toHaveLength(0);
});
});
describe('crawlPages', () => {
it('should crawl multiple pages concurrently', async () => {
const mockCrawlResult = {
content: 'Page content',
description: 'Page description',
title: 'Page title',
url: 'https://example.com',
};
const mockCrawler = {
crawl: vi.fn().mockResolvedValue(mockCrawlResult),
};
vi.mocked(Crawler).mockImplementation(() => mockCrawler as any);
searchService = new SearchService();
const urls = ['https://example1.com', 'https://example2.com', 'https://example3.com'];
const result = await searchService.crawlPages({ urls });
expect(Crawler).toHaveBeenCalledWith({ impls: [] });
expect(mockCrawler.crawl).toHaveBeenCalledTimes(3);
expect(result.results).toHaveLength(3);
expect(result.results[0]).toBe(mockCrawlResult);
});
it('should use crawler implementations from env', async () => {
vi.mocked(toolsEnv).CRAWLER_IMPLS = 'jina,reader';
const mockCrawler = {
crawl: vi.fn().mockResolvedValue({}),
};
vi.mocked(Crawler).mockImplementation(() => mockCrawler as any);
searchService = new SearchService();
await searchService.crawlPages({ urls: ['https://example.com'] });
expect(Crawler).toHaveBeenCalledWith({ impls: ['jina', 'reader'] });
});
it('should pass impls parameter to crawler.crawl', async () => {
const mockCrawler = {
crawl: vi.fn().mockResolvedValue({}),
};
vi.mocked(Crawler).mockImplementation(() => mockCrawler as any);
searchService = new SearchService();
await searchService.crawlPages({
impls: ['jina'],
urls: ['https://example.com'],
});
expect(mockCrawler.crawl).toHaveBeenCalledWith({
impls: ['jina'],
url: 'https://example.com',
});
});
});
});
+27 -2
View File
@@ -1,13 +1,13 @@
import { SearchParams, SearchQuery } from '@lobechat/types';
import { CrawlImplType, Crawler } from '@lobechat/web-crawler';
import pMap from 'p-map';
import { toolsEnv } from '@/envs/tools';
import { SearchParams } from '@/types/tool/search';
import { SearchImplType, SearchServiceImpl, createSearchServiceImpl } from './impls';
const parseImplEnv = (envString: string = '') => {
// 处理全角逗号和多余空格
// Handle full-width commas and extra whitespace
const envValue = envString.replaceAll('', ',').trim();
return envValue.split(',').filter(Boolean);
};
@@ -53,6 +53,31 @@ export class SearchService {
async query(query: string, params?: SearchParams) {
return this.searchImpl.query(query, params);
}
async webSearch({ query, searchCategories, searchEngines, searchTimeRange }: SearchQuery) {
let data = await this.query(query, {
searchCategories: searchCategories,
searchEngines: searchEngines,
searchTimeRange: searchTimeRange,
});
// First retry: remove search engine restrictions if no results found
if (data.results.length === 0 && searchEngines && searchEngines?.length > 0) {
const paramsExcludeSearchEngines = {
searchCategories: searchCategories,
searchEngines: undefined,
searchTimeRange: searchTimeRange,
};
data = await this.query(query, paramsExcludeSearchEngines);
}
// Second retry: remove all restrictions if still no results found
if (data?.results.length === 0) {
data = await this.query(query);
}
return data;
}
}
// Add a default exported instance for convenience
+1 -2
View File
@@ -1,3 +1,4 @@
import { LobeTool } from '@lobechat/types';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -16,7 +17,6 @@ import { WebBrowsingManifest } from '@/tools/web-browsing';
import { ChatErrorType } from '@/types/index';
import { ChatImageItem, ChatMessage } from '@/types/message';
import { ChatStreamPayload, type OpenAIChatMessage } from '@/types/openai/chat';
import { LobeTool } from '@/types/tool';
import * as helpers from './helper';
import { chatService } from './index';
@@ -1249,7 +1249,6 @@ describe('ChatService private methods', () => {
expect(fetchSSEOptions.responseAnimation).toEqual({
speed: 20,
text: 'fadeIn',
toolsCalling: 'fadeIn',
});
});
});
+10 -5
View File
@@ -1,12 +1,10 @@
import { CURRENT_VERSION, isDesktop } from '@lobechat/const';
import { ChatToolPayload, CheckMcpInstallResult, CustomPluginMetadata } from '@lobechat/types';
import { isLocalOrPrivateUrl, safeParseJSON } from '@lobechat/utils';
import { PluginManifest } from '@lobehub/market-sdk';
import { CallReportRequest } from '@lobehub/market-types';
import { CURRENT_VERSION, isDesktop } from '@/const/version';
import { desktopClient, toolsClient } from '@/libs/trpc/client';
import { ChatToolPayload } from '@/types/message';
import { CheckMcpInstallResult } from '@/types/plugins';
import { CustomPluginMetadata } from '@/types/tool/plugin';
import { safeParseJSON } from '@/utils/safeParseJSON';
import { discoverService } from './discover';
@@ -139,6 +137,13 @@ class MCPService {
},
signal?: AbortSignal,
) {
// 如果是 Desktop 模式且 URL 是本地地址,使用 desktopClient
// 这样可以避免在生产环境中通过远程服务器访问用户本地服务
if (isDesktop && isLocalOrPrivateUrl(params.url)) {
return desktopClient.mcp.getStreamableMcpServerManifest.query(params, { signal });
}
// 否则使用 toolsClient(通过服务器中转)
return toolsClient.mcp.getStreamableMcpServerManifest.query(params, { signal });
}
+1 -1
View File
@@ -1,9 +1,9 @@
import { LobeTool } from '@lobechat/types';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PluginModel } from '@/database/_deprecated/models/plugin';
import { DB_Plugin } from '@/database/_deprecated/schemas/plugin';
import { LobeTool } from '@/types/tool';
import { LobeToolCustomPlugin } from '@/types/tool/plugin';
import { ClientService } from './_deprecated';
+1 -1
View File
@@ -1,7 +1,7 @@
import { LobeTool } from '@lobechat/types';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { PluginModel } from '@/database/_deprecated/models/plugin';
import { LobeTool } from '@/types/tool';
import { LobeToolCustomPlugin } from '@/types/tool/plugin';
import { IPluginService, InstallPluginParams } from './type';
+1 -1
View File
@@ -1,10 +1,10 @@
import { LobeTool } from '@lobechat/types';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clientDB, initializeDB } from '@/database/client/db';
import { userInstalledPlugins, users } from '@/database/schemas';
import { LobeTool } from '@/types/tool';
import { LobeToolCustomPlugin } from '@/types/tool/plugin';
import { ClientService } from './client';
+2 -1
View File
@@ -1,7 +1,8 @@
import { LobeTool } from '@lobechat/types';
import { clientDB } from '@/database/client/db';
import { PluginModel } from '@/database/models/plugin';
import { BaseClientService } from '@/services/baseClientService';
import { LobeTool } from '@/types/tool';
import { IPluginService } from './type';
+1 -1
View File
@@ -1,6 +1,6 @@
import { LobeTool } from '@lobechat/types';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { LobeTool } from '@/types/tool';
import { LobeToolCustomPlugin } from '@/types/tool/plugin';
export interface InstallPluginParams {
+8 -2
View File
@@ -1,3 +1,5 @@
import { SearchQuery } from '@lobechat/types';
import { toolsClient } from '@/libs/trpc/client';
class SearchService {
@@ -9,8 +11,12 @@ class SearchService {
return toolsClient.search.crawlPages.mutate({ urls: [url] });
}
crawlPages(urls: string[]) {
return toolsClient.search.crawlPages.mutate({ urls });
crawlPages(params: { urls: string[] }) {
return toolsClient.search.crawlPages.mutate(params);
}
async webSearch(params: SearchQuery) {
return toolsClient.search.webSearch.query(params);
}
}
@@ -1,4 +1,5 @@
import { crawlResultsPrompt, searchResultsPrompt } from '@lobechat/prompts';
import { SearchContent, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { act, renderHook } from '@testing-library/react';
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -7,12 +8,11 @@ import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { CRAWL_CONTENT_LIMITED_COUNT } from '@/tools/web-browsing/const';
import { ChatMessage } from '@/types/message';
import { SearchContent, SearchQuery, UniformSearchResponse } from '@/types/tool/search';
// Mock services
vi.mock('@/services/search', () => ({
searchService: {
search: vi.fn(),
webSearch: vi.fn(),
crawlPages: vi.fn(),
},
}));
@@ -59,7 +59,7 @@ describe('search actions', () => {
query: 'test',
};
(searchService.search as Mock).mockResolvedValue(mockResponse);
(searchService.webSearch as Mock).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useChatStore());
const { search } = result.current;
@@ -82,8 +82,9 @@ describe('search actions', () => {
},
];
expect(searchService.search).toHaveBeenCalledWith('test query', {
expect(searchService.webSearch).toHaveBeenCalledWith({
searchEngines: ['google'],
query: 'test query',
});
expect(result.current.searchLoading[messageId]).toBe(false);
expect(result.current.internal_updateMessageContent).toHaveBeenCalledWith(
@@ -92,7 +93,7 @@ describe('search actions', () => {
);
});
it('should handle empty search results and retry with default engine', async () => {
it('should handle empty search results', async () => {
const emptyResponse: UniformSearchResponse = {
results: [],
costTime: 1,
@@ -100,27 +101,7 @@ describe('search actions', () => {
query: 'test',
};
const retryResponse: UniformSearchResponse = {
results: [
{
title: 'Retry Result',
content: 'Retry Content',
url: 'https://retry.com',
category: 'general',
engines: ['google'],
parsedUrl: 'retry.com',
score: 1,
},
],
costTime: 1,
resultNumbers: 1,
query: 'test',
};
(searchService.search as Mock)
.mockResolvedValueOnce(emptyResponse)
.mockResolvedValueOnce(emptyResponse)
.mockResolvedValueOnce(retryResponse);
(searchService.webSearch as Mock).mockResolvedValue(emptyResponse);
const { result } = renderHook(() => useChatStore());
const { search } = result.current;
@@ -136,27 +117,21 @@ describe('search actions', () => {
await search(messageId, query);
});
expect(searchService.search).toHaveBeenCalledTimes(3);
expect(searchService.search).toHaveBeenNthCalledWith(1, 'test query', {
expect(searchService.webSearch).toHaveBeenCalledWith({
searchEngines: ['custom-engine'],
searchTimeRange: 'year',
});
expect(searchService.search).toHaveBeenNthCalledWith(2, 'test query', {
searchTimeRange: 'year',
});
expect(result.current.updatePluginArguments).toHaveBeenCalledWith(messageId, {
query: 'test query',
});
expect(searchService.search).toHaveBeenNthCalledWith(3, 'test query');
expect(result.current.updatePluginArguments).toHaveBeenCalledWith(messageId, {
optionalParams: undefined,
query: 'test query',
});
expect(result.current.searchLoading[messageId]).toBe(false);
expect(result.current.internal_updateMessageContent).toHaveBeenCalledWith(
messageId,
searchResultsPrompt([]),
);
});
it('should handle search error', async () => {
const error = new Error('Search failed');
(searchService.search as Mock).mockRejectedValue(error);
(searchService.webSearch as Mock).mockRejectedValue(error);
const { result } = renderHook(() => useChatStore());
const { search } = result.current;
@@ -176,6 +151,10 @@ describe('search actions', () => {
type: 'PluginServerError',
});
expect(result.current.searchLoading[messageId]).toBe(false);
expect(result.current.internal_updateMessageContent).toHaveBeenCalledWith(
messageId,
'Search failed',
);
});
});
@@ -1,18 +1,12 @@
import { crawlResultsPrompt, searchResultsPrompt } from '@lobechat/prompts';
import { crawlResultsPrompt } from '@lobechat/prompts';
import { CreateMessageParams, SEARCH_SEARXNG_NOT_CONFIG, SearchQuery } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
import { StateCreator } from 'zustand/vanilla';
import { searchService } from '@/services/search';
import { chatSelectors } from '@/store/chat/selectors';
import { ChatStore } from '@/store/chat/store';
import { CRAWL_CONTENT_LIMITED_COUNT, SEARCH_ITEM_LIMITED_COUNT } from '@/tools/web-browsing/const';
import { CreateMessageParams } from '@/types/message';
import {
SEARCH_SEARXNG_NOT_CONFIG,
SearchContent,
SearchQuery,
UniformSearchResponse,
} from '@/types/tool/search';
import { nanoid } from '@/utils/uuid';
import { WebBrowsingExecutionRuntime } from '@/tools/web-browsing/ExecutionRuntime';
export interface SearchAction {
crawlMultiPages: (
@@ -40,6 +34,8 @@ export interface SearchAction {
) => Promise<void>;
}
const runtime = new WebBrowsingExecutionRuntime({ searchService });
export const searchSlice: StateCreator<
ChatStore,
[['zustand/devtools', never]],
@@ -50,28 +46,18 @@ export const searchSlice: StateCreator<
const { internal_updateMessageContent } = get();
get().toggleSearchLoading(id, true);
try {
const response = await searchService.crawlPages(params.urls);
const { content, success, error, state } = await runtime.crawlMultiPages(params);
await get().updatePluginState(id, response);
await internal_updateMessageContent(id, content);
if (success) {
await get().updatePluginState(id, state);
} else {
await get().internal_updatePluginError(id, error);
}
get().toggleSearchLoading(id, false);
const { results } = response;
if (!results) return;
const content = results.map((item) =>
'errorMessage' in item
? item
: {
...item.data,
// if crawl too many content
// slice the top 10000 char
content: item.data.content?.slice(0, CRAWL_CONTENT_LIMITED_COUNT),
},
);
// Convert to XML format to save tokens
const xmlContent = crawlResultsPrompt(content as any);
await internal_updateMessageContent(id, xmlContent);
// if aiSummary is true, then trigger ai message
return aiSummary;
@@ -132,36 +118,15 @@ export const searchSlice: StateCreator<
openToolUI(newMessageId, message.plugin.identifier);
},
search: async (id, { query, ...params }, aiSummary = true) => {
search: async (id, params, aiSummary = true) => {
get().toggleSearchLoading(id, true);
let data: UniformSearchResponse | undefined;
try {
// 首次查询
data = await searchService.search(query, params);
// 如果没有搜索到结果,则执行第一次重试(移除搜索引擎限制)
if (
data?.results.length === 0 &&
params?.searchEngines &&
params?.searchEngines?.length > 0
) {
const paramsExcludeSearchEngines = {
...params,
searchEngines: undefined,
};
data = await searchService.search(query, paramsExcludeSearchEngines);
get().updatePluginArguments(id, paramsExcludeSearchEngines);
}
const { content, success, error, state } = await runtime.search(params);
// 如果仍然没有搜索到结果,则执行第二次重试(移除所有限制)
if (data?.results.length === 0) {
data = await searchService.search(query);
get().updatePluginArguments(id, { query });
}
await get().updatePluginState(id, data);
} catch (e) {
if ((e as Error).message === SEARCH_SEARXNG_NOT_CONFIG) {
if (success) {
await get().updatePluginState(id, state);
} else {
if ((error as Error).message === SEARCH_SEARXNG_NOT_CONFIG) {
await get().internal_updateMessagePluginError(id, {
body: {
provider: 'searxng',
@@ -171,8 +136,8 @@ export const searchSlice: StateCreator<
});
} else {
await get().internal_updateMessagePluginError(id, {
body: e,
message: (e as Error).message,
body: error,
message: (error as Error).message,
type: 'PluginServerError',
});
}
@@ -180,23 +145,7 @@ export const searchSlice: StateCreator<
get().toggleSearchLoading(id, false);
if (!data) return;
// add LIMITED_COUNT search results to message content
const searchContent: SearchContent[] = data.results
.slice(0, SEARCH_ITEM_LIMITED_COUNT)
.map((item) => ({
title: item.title,
url: item.url,
...(item.content && { content: item.content }),
...(item.publishedDate && { publishedDate: item.publishedDate }),
...(item.imgSrc && { imgSrc: item.imgSrc }),
...(item.thumbnail && { thumbnail: item.thumbnail }),
}));
// Convert to XML format to save tokens
const xmlContent = searchResultsPrompt(searchContent);
await get().internal_updateMessageContent(id, xmlContent);
await get().internal_updateMessageContent(id, content);
// 如果 aiSummary 为 true,则会自动触发总结
return aiSummary;
+6 -6
View File
@@ -1,3 +1,9 @@
import {
DiscoverPluginDetail,
IdentifiersResponse,
PluginListResponse,
PluginQueryParams,
} from '@lobechat/types';
import { CategoryItem, CategoryListQuery } from '@lobehub/market-sdk';
import useSWR, { type SWRResponse } from 'swr';
import type { StateCreator } from 'zustand/vanilla';
@@ -5,12 +11,6 @@ import type { StateCreator } from 'zustand/vanilla';
import { discoverService } from '@/services/discover';
import { DiscoverStore } from '@/store/discover';
import { globalHelpers } from '@/store/global/helpers';
import {
DiscoverPluginDetail,
IdentifiersResponse,
PluginListResponse,
PluginQueryParams,
} from '@/types/discover';
export interface PluginAction {
usePluginCategories: (params: CategoryListQuery) => SWRResponse<CategoryItem[]>;
+1 -1
View File
@@ -1,7 +1,7 @@
import { LobeTool } from '@lobechat/types';
import { PluginSchema } from '@lobehub/chat-plugin-sdk';
import { MetaData } from '@/types/meta';
import { LobeTool } from '@/types/tool';
const getPluginFormList = (list: LobeTool[], id: string) => list?.find((p) => p.identifier === id);
@@ -1,5 +1,6 @@
import { LobeBuiltinTool } from '@lobechat/types';
import { builtinTools } from '@/tools';
import { LobeBuiltinTool } from '@/types/tool';
export interface BuiltinToolState {
builtinToolLoading: Record<string, boolean>;
+1 -1
View File
@@ -1,3 +1,4 @@
import { LobeTool } from '@lobechat/types';
import { t } from 'i18next';
import { produce } from 'immer';
import { uniqBy } from 'lodash-es';
@@ -10,7 +11,6 @@ import { toolService } from '@/services/tool';
import { globalHelpers } from '@/store/global/helpers';
import { pluginStoreSelectors } from '@/store/tool/selectors';
import { DiscoverPluginItem, PluginListResponse, PluginQueryParams } from '@/types/discover';
import { LobeTool } from '@/types/tool';
import { PluginInstallError } from '@/types/tool/plugin';
import { sleep } from '@/utils/sleep';
import { setNamespace } from '@/utils/storeDebug';
+1 -1
View File
@@ -1,10 +1,10 @@
import { LobeTool } from '@lobechat/types';
import { LobeChatPluginMeta } from '@lobehub/chat-plugin-sdk';
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { pluginService } from '@/services/plugin';
import { DiscoverPluginItem } from '@/types/discover';
import { LobeTool } from '@/types/tool';
import { merge } from '@/utils/merge';
import { useToolStore } from '../../store';
+1 -1
View File
@@ -1,4 +1,4 @@
import { LobeTool } from '@/types/tool';
import { LobeTool } from '@lobechat/types';
export type PluginsSettings = Record<string, any>;
+2 -1
View File
@@ -1,5 +1,6 @@
import { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from '@/tools/artifacts/systemRole';
import { BuiltinToolManifest } from '@/types/tool';
export const ArtifactsManifest: BuiltinToolManifest = {
api: [],
+1 -1
View File
@@ -1,10 +1,10 @@
import { BuiltinRenderProps } from '@lobechat/types';
import { ActionIcon, PreviewGroup } from '@lobehub/ui';
import { Download } from 'lucide-react';
import { memo, useRef } from 'react';
import { Flexbox } from 'react-layout-kit';
import { fileService } from '@/services/file';
import { BuiltinRenderProps } from '@/types/tool';
import { DallEImageItem } from '@/types/tool/dalle';
import GalleyGrid from './GalleyGrid';
+1 -1
View File
@@ -1,4 +1,4 @@
import { BuiltinToolManifest } from '@/types/tool';
import { BuiltinToolManifest } from '@lobechat/types';
// import {SiOpenai} from "@icons-pack/react-simple-icons";
+6
View File
@@ -0,0 +1,6 @@
import { WebBrowsingManifest } from './web-browsing';
import { WebBrowsingExecutionRuntime } from './web-browsing/ExecutionRuntime';
export const BuiltinToolServerRuntimes: Record<string, any> = {
[WebBrowsingManifest.identifier]: WebBrowsingExecutionRuntime,
};
+2 -1
View File
@@ -1,5 +1,6 @@
import { LobeBuiltinTool } from '@lobechat/types';
import { isDesktop } from '@/const/version';
import { LobeBuiltinTool } from '@/types/tool';
import { ArtifactsManifest } from './artifacts';
import { CodeInterpreterManifest } from './code-interpreter';
+1 -1
View File
@@ -1,8 +1,8 @@
import { LocalFileItem } from '@lobechat/electron-client-ipc';
import { BuiltinRenderProps } from '@lobechat/types';
import { memo } from 'react';
import { LocalSystemApiName } from '@/tools/local-system';
import { BuiltinRenderProps } from '@/types/tool';
import ListFiles from './ListFiles';
import ReadLocalFile from './ReadLocalFile';
+1 -1
View File
@@ -1,4 +1,4 @@
import { BuiltinToolManifest } from '@/types/tool';
import { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
+1 -1
View File
@@ -1,4 +1,4 @@
import { BuiltinPortal } from '@/types/tool';
import { BuiltinPortal } from '@lobechat/types';
import { WebBrowsingManifest } from './web-browsing';
import WebBrowsing from './web-browsing/Portal';
+1 -1
View File
@@ -1,4 +1,4 @@
import { BuiltinRender } from '@/types/tool';
import { BuiltinRender } from '@lobechat/types';
import { CodeInterpreterManifest } from './code-interpreter';
import CodeInterpreterRender from './code-interpreter/Render';
@@ -0,0 +1,74 @@
import { crawlResultsPrompt, searchResultsPrompt } from '@lobechat/prompts';
import {
BuiltinServerRuntimeOutput,
CrawlMultiPagesQuery,
CrawlSinglePageQuery,
SearchContent,
SearchQuery,
SearchServiceImpl,
} from '@lobechat/types';
import { CRAWL_CONTENT_LIMITED_COUNT, SEARCH_ITEM_LIMITED_COUNT } from '../const';
export class WebBrowsingExecutionRuntime {
private searchService: SearchServiceImpl;
constructor(options: { searchService: SearchServiceImpl }) {
this.searchService = options.searchService;
}
async search(args: SearchQuery): Promise<BuiltinServerRuntimeOutput> {
try {
const data = await this.searchService.webSearch(args as SearchQuery);
// add LIMITED_COUNT search results to message content
const searchContent: SearchContent[] = data.results
.slice(0, SEARCH_ITEM_LIMITED_COUNT)
.map((item) => ({
title: item.title,
url: item.url,
...(item.content && { content: item.content }),
...(item.publishedDate && { publishedDate: item.publishedDate }),
...(item.imgSrc && { imgSrc: item.imgSrc }),
...(item.thumbnail && { thumbnail: item.thumbnail }),
}));
// Convert to XML format to save tokens
const xmlContent = searchResultsPrompt(searchContent);
return { content: xmlContent, state: data, success: true };
} catch (e) {
return { content: (e as Error).message, error: e, success: false };
}
}
async crawlSinglePage(args: CrawlSinglePageQuery): Promise<BuiltinServerRuntimeOutput> {
return this.crawlMultiPages({ urls: [args.url] });
}
async crawlMultiPages(args: CrawlMultiPagesQuery): Promise<BuiltinServerRuntimeOutput> {
const response = await this.searchService.crawlPages({
urls: args.urls,
});
const { results } = response;
const content = results.map((item) =>
'errorMessage' in item
? item
: {
...item.data,
// if crawl too many content
// slice the top 10000 char
content: item.data.content?.slice(0, CRAWL_CONTENT_LIMITED_COUNT),
},
);
const xmlContent = crawlResultsPrompt(content as any);
return {
content: xmlContent,
state: response,
success: true,
};
}
}
@@ -1,3 +1,5 @@
import { CrawlResult } from '@lobechat/types';
import { CrawlSuccessResult } from '@lobechat/web-crawler';
import { Alert, CopyButton, Highlighter, Icon, Markdown, Segmented, Text } from '@lobehub/ui';
import { Descriptions } from 'antd';
import { createStyles } from 'antd-style';
@@ -7,8 +9,7 @@ import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { CRAWL_CONTENT_LIMITED_COUNT } from '@/tools/web-browsing/const';
import { CrawlResult } from '@/types/tool/crawler';
import { CRAWL_CONTENT_LIMITED_COUNT } from '../../const';
const useStyles = createStyles(({ token, css }) => {
return {
@@ -132,7 +133,7 @@ const PageContent = memo<PageContentProps>(({ result }) => {
);
}
const { url, title, description, content } = result.data;
const { url, title, description, content, siteName } = result.data as CrawlSuccessResult;
return (
<Flexbox gap={24}>
<Flexbox gap={8}>
@@ -153,7 +154,7 @@ const PageContent = memo<PageContentProps>(({ result }) => {
</Text>
)}
<Flexbox align={'center'} className={styles.url} gap={4} horizontal>
{result.data.siteName && <div>{result.data.siteName} · </div>}
{siteName && <div>{siteName} · </div>}
<Link
className={styles.url}
href={url}
@@ -1,7 +1,7 @@
import { CrawlResult } from '@lobechat/types';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { CrawlResult } from '@/types/tool/crawler';
import PageContent from '../PageContent';
@@ -3,8 +3,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { EngineAvatarGroup } from '@/tools/web-browsing/components/EngineAvatar';
import { EngineAvatarGroup } from '../../../../components/EngineAvatar';
import CategoryAvatar from './CategoryAvatar';
interface TitleExtraProps {
@@ -1,11 +1,10 @@
import { UniformSearchResult } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { Avatar as AntAvatar } from 'antd';
import { createStyles } from 'antd-style';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { UniformSearchResult } from '@/types/tool/search';
import { ENGINE_ICON_MAP } from '../../../../const';
import TitleExtra from './TitleExtra';
@@ -1,10 +1,10 @@
import { UniformSearchResult } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import WebFavicon from '@/components/WebFavicon';
import { UniformSearchResult } from '@/types/tool/search';
import TitleExtra from './TitleExtra';
import Video from './Video';
@@ -1,8 +1,8 @@
import { UniformSearchResult } from '@lobechat/types';
import React, { memo, useCallback } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { UniformSearchResult } from '@/types/tool/search';
import { SEARCH_ITEM_LIMITED_COUNT } from '../../../const';
import Item from './SearchItem';
interface ResultListProps {
@@ -11,7 +11,9 @@ interface ResultListProps {
const ResultList = memo<ResultListProps>(({ dataSources }) => {
const itemContent = useCallback(
(index: number, result: UniformSearchResult) => <Item {...result} highlight={index < 15} />,
(index: number, result: UniformSearchResult) => (
<Item {...result} highlight={index < SEARCH_ITEM_LIMITED_COUNT} />
),
[],
);
@@ -1,3 +1,4 @@
import { SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Skeleton } from 'antd';
import { uniq } from 'lodash-es';
import { memo } from 'react';
@@ -5,7 +6,6 @@ import { Flexbox } from 'react-layout-kit';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/selectors';
import { SearchQuery, UniformSearchResponse } from '@/types/tool/search';
import SearchBar from '../../components/SearchBar';
import Footer from './Footer';
+1 -3
View File
@@ -1,9 +1,7 @@
import { CrawlPluginState, SearchQuery , BuiltinPortalProps } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '@/tools/web-browsing';
import { BuiltinPortalProps } from '@/types/tool';
import { CrawlPluginState } from '@/types/tool/crawler';
import { SearchQuery } from '@/types/tool/search';
import PageContent from './PageContent';
import PageContents from './PageContents';
@@ -123,7 +123,7 @@ const CrawlerResultCard = memo<CrawlerData>(({ result, messageId, crawler, origi
);
}
const { url, title, description } = result;
const { url, title, description } = result as CrawlSuccessResult;
return (
<Flexbox
@@ -1,10 +1,10 @@
import { CrawlPluginState } from '@lobechat/types';
import { CrawlErrorResult } from '@lobechat/web-crawler';
import { ScrollShadow } from '@lobehub/ui';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useIsMobile } from '@/hooks/useIsMobile';
import { CrawlPluginState } from '@/types/tool/crawler';
import Loading from './Loading';
import Result from './Result';
@@ -1,3 +1,4 @@
import { SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { ActionIcon } from '@lobehub/ui';
import { uniq } from 'lodash-es';
import { XIcon } from 'lucide-react';
@@ -6,7 +7,6 @@ import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/selectors';
import { SearchQuery, UniformSearchResponse } from '@/types/tool/search';
import SearchBar from '../../../components/SearchBar';
import SearchView from './SearchView';
@@ -1,3 +1,4 @@
import { UniformSearchResult } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import Link from 'next/link';
@@ -5,7 +6,6 @@ import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import WebFavicon from '@/components/WebFavicon';
import { UniformSearchResult } from '@/types/tool/search';
const useStyles = createStyles(({ css, token }) => ({
container: css`
@@ -1,3 +1,4 @@
import { SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Icon, Text } from '@lobehub/ui';
import { Button, Skeleton } from 'antd';
import { uniq } from 'lodash-es';
@@ -9,7 +10,6 @@ import { Flexbox } from 'react-layout-kit';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/selectors';
import { SearchQuery, UniformSearchResponse } from '@/types/tool/search';
import SearchResultItem from './SearchResultItem';
import ShowMore from './ShowMore';
@@ -1,9 +1,9 @@
import { SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Alert, Highlighter } from '@lobehub/ui';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { ChatMessagePluginError } from '@/types/message';
import { SearchQuery, UniformSearchResponse } from '@/types/tool/search';
import ConfigForm from './ConfigForm';
import SearchQueryView from './SearchQuery';
+8 -3
View File
@@ -1,10 +1,15 @@
import {
CrawlMultiPagesQuery,
CrawlPluginState,
CrawlSinglePageQuery,
SearchContent,
SearchQuery,
UniformSearchResponse,
BuiltinRenderProps } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '@/tools/web-browsing';
import PageContent from '@/tools/web-browsing/Render/PageContent';
import { BuiltinRenderProps } from '@/types/tool';
import { CrawlMultiPagesQuery, CrawlPluginState, CrawlSinglePageQuery } from '@/types/tool/crawler';
import { SearchContent, SearchQuery, UniformSearchResponse } from '@/types/tool/search';
import Search from './Search';
@@ -1,3 +1,4 @@
import { SearchQuery } from '@lobechat/types';
import { Button, Input, Select, Text, Tooltip } from '@lobehub/ui';
import { Checkbox, Radio, Space } from 'antd';
import { SearchIcon } from 'lucide-react';
@@ -8,7 +9,6 @@ import { Flexbox } from 'react-layout-kit';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useChatStore } from '@/store/chat';
import { chatToolSelectors } from '@/store/chat/selectors';
import { SearchQuery } from '@/types/tool/search';
import { CATEGORY_ICON_MAP, ENGINE_ICON_MAP } from '../const';
import { CategoryAvatar } from './CategoryAvatar';
+1
View File
@@ -32,6 +32,7 @@ export const ENGINE_ICON_MAP: Record<string, string> = {
'brave': 'https://icons.duckduckgo.com/ip3/brave.com.ico',
'brave.news': 'https://icons.duckduckgo.com/ip3/brave.com.ico',
'duckduckgo': 'https://icons.duckduckgo.com/ip3/www.duckduckgo.com.ico',
'github': 'https://icons.duckduckgo.com/ip3/github.com.ico',
'google': 'https://icons.duckduckgo.com/ip3/google.com.ico',
'google scholar': 'https://icons.duckduckgo.com/ip3/scholar.google.com.ico',
'npm': 'https://icons.duckduckgo.com/ip3/npmjs.com.ico',
+1 -2
View File
@@ -1,7 +1,6 @@
import { BuiltinToolManifest } from '@lobechat/types';
import dayjs from 'dayjs';
import { BuiltinToolManifest } from '@/types/tool';
import { systemPrompt } from './systemRole';
export const WebBrowsingApiName = {