mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
🐛 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:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ export type ResponseAnimation =
|
||||
| {
|
||||
speed?: number;
|
||||
text?: ResponseAnimationStyle;
|
||||
toolsCalling?: ResponseAnimationStyle;
|
||||
}
|
||||
| ResponseAnimationStyle;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -57,3 +57,10 @@ export interface BuiltinPlaceholderProps {
|
||||
}
|
||||
|
||||
export type BuiltinPlaceholder = (props: BuiltinPlaceholderProps) => ReactNode;
|
||||
|
||||
export interface BuiltinServerRuntimeOutput {
|
||||
content: string;
|
||||
error?: any;
|
||||
state?: any;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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,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';
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { LobeToolRenderType } from '@/types/tool';
|
||||
import { LobeToolRenderType } from '@lobechat/types';
|
||||
|
||||
export interface V4ChatPluginPayload {
|
||||
apiName: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LobeToolRenderType } from '@/types/tool';
|
||||
import { LobeToolRenderType } from '@lobechat/types';
|
||||
|
||||
import { V4ChatPluginPayload } from './v4';
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { SearchParams, UniformSearchResponse } from '@/types/tool/search';
|
||||
import { SearchParams, UniformSearchResponse } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Search service implementation interface
|
||||
|
||||
@@ -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 = 'tavily,brave';
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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
@@ -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,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,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,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';
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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,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,4 +1,4 @@
|
||||
import { LobeTool } from '@/types/tool';
|
||||
import { LobeTool } from '@lobechat/types';
|
||||
|
||||
export type PluginsSettings = Record<string, any>;
|
||||
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import { BuiltinToolManifest } from '@/types/tool';
|
||||
import { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
// import {SiOpenai} from "@icons-pack/react-simple-icons";
|
||||
|
||||
|
||||
@@ -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
@@ -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,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,4 +1,4 @@
|
||||
import { BuiltinToolManifest } from '@/types/tool';
|
||||
import { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { systemPrompt } from './systemRole';
|
||||
|
||||
|
||||
@@ -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,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,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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
import { BuiltinToolManifest } from '@lobechat/types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { BuiltinToolManifest } from '@/types/tool';
|
||||
|
||||
import { systemPrompt } from './systemRole';
|
||||
|
||||
export const WebBrowsingApiName = {
|
||||
|
||||
Reference in New Issue
Block a user