feat: Optimize the code

This commit is contained in:
pomelo-nwu
2025-10-27 11:01:48 +08:00
parent f9f6eb52dd
commit b1ece177b7
10 changed files with 296 additions and 282 deletions

View File

@@ -908,29 +908,8 @@ export class Config {
return this.tavilyApiKey; return this.tavilyApiKey;
} }
getWebSearchConfig(): getWebSearchConfig() {
| { return this.webSearch;
provider: Array<{
type: 'tavily' | 'google' | 'dashscope';
config: Record<string, unknown>;
}>;
default: string;
}
| undefined {
if (!this.webSearch) {
return undefined;
}
return {
provider: this.webSearch.provider.map((p) => ({
type: p.type,
config: {
apiKey: p.apiKey,
searchEngineId: p.searchEngineId,
},
})),
default: this.webSearch.default,
};
} }
getIdeMode(): boolean { getIdeMode(): boolean {

View File

@@ -9,10 +9,11 @@ import type {
WebSearchResult, WebSearchResult,
WebSearchResultItem, WebSearchResultItem,
} from './types.js'; } from './types.js';
import { getErrorMessage } from '../../utils/errors.js'; import { WebSearchError } from './errors.js';
/** /**
* Base implementation for web search providers. * Base implementation for web search providers.
* Provides common functionality for error handling and result formatting.
*/ */
export abstract class BaseWebSearchProvider implements WebSearchProvider { export abstract class BaseWebSearchProvider implements WebSearchProvider {
abstract readonly name: string; abstract readonly name: string;
@@ -41,16 +42,19 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider {
*/ */
async search(query: string, signal: AbortSignal): Promise<WebSearchResult> { async search(query: string, signal: AbortSignal): Promise<WebSearchResult> {
if (!this.isAvailable()) { if (!this.isAvailable()) {
throw new Error( throw new WebSearchError(
`${this.name} provider is not available. Please check your configuration.`, this.name,
'Provider is not available. Please check your configuration.',
); );
} }
try { try {
return await this.performSearch(query, signal); return await this.performSearch(query, signal);
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = getErrorMessage(error); if (error instanceof WebSearchError) {
throw new Error(`Error during ${this.name} search: ${errorMessage}`); throw error;
}
throw new WebSearchError(this.name, 'Search failed', error);
} }
} }
@@ -58,6 +62,7 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider {
* Format search results into a consistent format. * Format search results into a consistent format.
* @param results Raw results from the provider * @param results Raw results from the provider
* @param query The original search query * @param query The original search query
* @param answer Optional answer from the provider
* @returns Formatted search results * @returns Formatted search results
*/ */
protected formatResults( protected formatResults(
@@ -71,31 +76,4 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider {
results, results,
}; };
} }
/**
* Create a formatted source list for display.
* @param results Search result items
* @returns Formatted source list
*/
protected createSourceList(results: WebSearchResultItem[]): string {
return results
.map((r, i) => `[${i + 1}] ${r.title || 'Untitled'} (${r.url})`)
.join('\n');
}
/**
* Build a concise summary from search results.
* @param results Search result items
* @param maxResults Maximum number of results to include
* @returns Concise summary string
*/
protected buildSummary(
results: WebSearchResultItem[],
maxResults: number = 3,
): string {
return results
.slice(0, maxResults)
.map((r, i) => `${i + 1}. ${r.title} - ${r.url}`)
.join('\n');
}
} }

View File

@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Custom error class for web search operations.
*/
export class WebSearchError extends Error {
constructor(
readonly provider: string,
message: string,
readonly originalError?: unknown,
) {
super(`[${provider}] ${message}`);
this.name = 'WebSearchError';
}
}

View File

@@ -13,16 +13,21 @@ import {
type ToolInfoConfirmationDetails, type ToolInfoConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
} from '../tools.js'; } from '../tools.js';
import { ToolErrorType } from '../tool-error.js';
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { ApprovalMode } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js';
import { getErrorMessage } from '../../utils/errors.js'; import { getErrorMessage } from '../../utils/errors.js';
import { WebSearchProviderFactory } from './provider-factory.js'; import { buildContentWithSources, buildSummary } from './utils.js';
import { TavilyProvider } from './providers/tavily-provider.js';
import { GoogleProvider } from './providers/google-provider.js';
import { DashScopeProvider } from './providers/dashscope-provider.js';
import type { import type {
WebSearchToolParams, WebSearchToolParams,
WebSearchToolResult, WebSearchToolResult,
WebSearchProvider, WebSearchProvider,
WebSearchResultItem, WebSearchResultItem,
WebSearchProviderConfig,
} from './types.js'; } from './types.js';
class WebSearchToolInvocation extends BaseToolInvocation< class WebSearchToolInvocation extends BaseToolInvocation<
@@ -37,7 +42,6 @@ class WebSearchToolInvocation extends BaseToolInvocation<
} }
override getDescription(): string { override getDescription(): string {
// Try to determine which provider will be used
const webSearchConfig = this.config.getWebSearchConfig(); const webSearchConfig = this.config.getWebSearchConfig();
const provider = const provider =
this.params.provider || webSearchConfig?.default || 'tavily'; this.params.provider || webSearchConfig?.default || 'tavily';
@@ -64,6 +68,103 @@ class WebSearchToolInvocation extends BaseToolInvocation<
return confirmationDetails; return confirmationDetails;
} }
/**
* Create a provider instance from configuration.
*/
private createProvider(config: WebSearchProviderConfig): WebSearchProvider {
switch (config.type) {
case 'tavily':
return new TavilyProvider(config);
case 'google':
return new GoogleProvider(config);
case 'dashscope':
return new DashScopeProvider(config);
default:
throw new Error('Unknown provider type');
}
}
/**
* Create all configured providers.
*/
private createProviders(
configs: WebSearchProviderConfig[],
): Map<string, WebSearchProvider> {
const providers = new Map<string, WebSearchProvider>();
for (const config of configs) {
try {
const provider = this.createProvider(config);
if (provider.isAvailable()) {
providers.set(config.type, provider);
}
} catch (error) {
console.warn(`Failed to create ${config.type} provider:`, error);
}
}
return providers;
}
/**
* Select the appropriate provider based on configuration and parameters.
*/
private selectProvider(
providers: Map<string, WebSearchProvider>,
requestedProvider?: string,
defaultProvider?: string,
): { provider: WebSearchProvider | null; error?: string } {
// Use requested provider if specified
if (requestedProvider) {
const provider = providers.get(requestedProvider);
if (!provider) {
const availableProviders = Array.from(providers.keys()).join(', ');
return {
provider: null,
error: `The specified provider "${requestedProvider}" is not available or not configured. Available providers: ${availableProviders}`,
};
}
return { provider };
}
// Use default provider if specified and available
if (defaultProvider && providers.has(defaultProvider)) {
const provider = providers.get(defaultProvider)!;
return { provider };
}
// Fallback to first available provider
const firstProvider = providers.values().next().value;
return { provider: firstProvider || null };
}
/**
* Format search results into a content string.
*/
private formatSearchResults(searchResult: {
answer?: string;
results: WebSearchResultItem[];
}): {
content: string;
sources: Array<{ title: string; url: string }>;
} {
const sources = searchResult.results.map((r) => ({
title: r.title,
url: r.url,
}));
let content = searchResult.answer?.trim() || '';
if (!content) {
// Fallback: build a concise summary from top results
content = buildSummary(sources, 3);
}
// Add sources section
content = buildContentWithSources(content, sources);
return { content, sources };
}
async execute(signal: AbortSignal): Promise<WebSearchToolResult> { async execute(signal: AbortSignal): Promise<WebSearchToolResult> {
const webSearchConfig = this.config.getWebSearchConfig(); const webSearchConfig = this.config.getWebSearchConfig();
if (!webSearchConfig) { if (!webSearchConfig) {
@@ -75,37 +176,35 @@ class WebSearchToolInvocation extends BaseToolInvocation<
}; };
} }
const providers = WebSearchProviderFactory.createProviders( const providers = this.createProviders(webSearchConfig.provider);
webSearchConfig.provider,
);
// Determine which provider to use const { provider: selectedProvider, error } = this.selectProvider(
let selectedProvider: WebSearchProvider | null = null;
if (this.params.provider) {
// Use the specified provider if available
const provider = providers.get(this.params.provider);
if (provider && provider.isAvailable()) {
selectedProvider = provider;
} else {
return {
llmContent: `The specified provider "${this.params.provider}" is not available or not configured. Available providers: ${Array.from(providers.keys()).join(', ')}`,
returnDisplay: `The WebSearch Provider "${this.params.provider}" not available.`,
};
}
} else {
// Use default provider
selectedProvider = WebSearchProviderFactory.getDefaultProvider(
providers, providers,
this.params.provider,
webSearchConfig.default, webSearchConfig.default,
); );
if (error) {
return {
llmContent: error,
returnDisplay: `Provider "${this.params.provider}" not available.`,
error: {
message: error,
type: ToolErrorType.INVALID_TOOL_PARAMS,
},
};
} }
if (!selectedProvider) { if (!selectedProvider) {
const errorMsg =
'Web search is disabled because no web search providers are available. Please check your configuration.';
return { return {
llmContent: llmContent: errorMsg,
'Web search is disabled because no web search providers are available. Please check your configuration.',
returnDisplay: 'Web search disabled. No available providers.', returnDisplay: 'Web search disabled. No available providers.',
error: {
message: errorMsg,
type: ToolErrorType.EXECUTION_FAILED,
},
}; };
} }
@@ -115,31 +214,7 @@ class WebSearchToolInvocation extends BaseToolInvocation<
signal, signal,
); );
const sources = searchResult.results.map((r: WebSearchResultItem) => ({ const { content, sources } = this.formatSearchResults(searchResult);
title: r.title,
url: r.url,
}));
const sourceListFormatted = sources.map(
(s: { title: string; url: string }, i: number) =>
`[${i + 1}] ${s.title || 'Untitled'} (${s.url})`,
);
let content = searchResult.answer?.trim() || '';
if (!content) {
// Fallback: build a concise summary from top results
content = sources
.slice(0, 3)
.map(
(s: { title: string; url: string }, i: number) =>
`${i + 1}. ${s.title} - ${s.url}`,
)
.join('\n');
}
if (sourceListFormatted.length > 0) {
content += `\n\nSources:\n${sourceListFormatted.join('\n')}`;
}
if (!content.trim()) { if (!content.trim()) {
return { return {
@@ -161,6 +236,10 @@ class WebSearchToolInvocation extends BaseToolInvocation<
return { return {
llmContent: `Error: ${errorMessage}`, llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error performing web search.`, returnDisplay: `Error performing web search.`,
error: {
message: errorMessage,
type: ToolErrorType.EXECUTION_FAILED,
},
}; };
} }
} }

View File

@@ -1,110 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { WebSearchProvider, WebSearchProviderConfig } from './types.js';
import {
TavilyProvider,
type TavilyConfig,
} from './providers/tavily-provider.js';
import {
GoogleProvider,
type GoogleConfig,
} from './providers/google-provider.js';
import {
DashScopeProvider,
type DashScopeConfig,
} from './providers/dashscope-provider.js';
/**
* Factory for creating web search providers based on configuration.
*/
export class WebSearchProviderFactory {
/**
* Create a web search provider from configuration.
* @param config Provider configuration
* @returns Web search provider instance
*/
static createProvider(config: WebSearchProviderConfig): WebSearchProvider {
switch (config.type) {
case 'tavily': {
const tavilyConfig = config.config as unknown as TavilyConfig;
if (!tavilyConfig?.apiKey) {
throw new Error('Tavily provider requires apiKey in configuration');
}
return new TavilyProvider(tavilyConfig);
}
case 'google': {
const googleConfig = config.config as unknown as GoogleConfig;
if (!googleConfig?.apiKey || !googleConfig?.searchEngineId) {
throw new Error(
'Google provider requires apiKey and searchEngineId in configuration',
);
}
return new GoogleProvider(googleConfig);
}
case 'dashscope': {
const dashscopeConfig = config.config as unknown as DashScopeConfig;
return new DashScopeProvider(dashscopeConfig);
}
default:
throw new Error(
`Unsupported web search provider type: ${(config as WebSearchProviderConfig).type}`,
);
}
}
/**
* Create multiple providers from configuration list.
* @param configs List of provider configurations
* @returns Map of provider name to provider instance
*/
static createProviders(
configs: WebSearchProviderConfig[],
): Map<string, WebSearchProvider> {
const providers = new Map<string, WebSearchProvider>();
for (const config of configs) {
try {
const provider = this.createProvider(config);
providers.set(config.type, provider);
} catch (error) {
console.warn(`Failed to create ${config.type} provider:`, error);
}
}
return providers;
}
/**
* Get the default provider from a list of providers.
* @param providers Map of available providers
* @param defaultProviderName Name of the default provider
* @returns Default provider or the first available provider
*/
static getDefaultProvider(
providers: Map<string, WebSearchProvider>,
defaultProviderName?: string,
): WebSearchProvider | null {
if (defaultProviderName && providers.has(defaultProviderName)) {
const provider = providers.get(defaultProviderName)!;
if (provider.isAvailable()) {
return provider;
}
}
// Fallback to first available provider
for (const provider of providers.values()) {
if (provider.isAvailable()) {
return provider;
}
}
return null;
}
}

View File

@@ -5,7 +5,12 @@
*/ */
import { BaseWebSearchProvider } from '../base-provider.js'; import { BaseWebSearchProvider } from '../base-provider.js';
import type { WebSearchResult, WebSearchResultItem } from '../types.js'; import { WebSearchError } from '../errors.js';
import type {
WebSearchResult,
WebSearchResultItem,
DashScopeProviderConfig,
} from '../types.js';
interface DashScopeSearchItem { interface DashScopeSearchItem {
_id: string; _id: string;
@@ -50,30 +55,19 @@ interface DashScopeSearchResponse {
success: boolean; success: boolean;
} }
/**
* Configuration for DashScope provider.
*/
export interface DashScopeConfig {
apiKey: string;
uid: string;
appId: string;
maxResults?: number;
scene?: string;
timeout?: number;
}
/** /**
* Web search provider using Alibaba Cloud DashScope API. * Web search provider using Alibaba Cloud DashScope API.
*/ */
export class DashScopeProvider extends BaseWebSearchProvider { export class DashScopeProvider extends BaseWebSearchProvider {
readonly name = 'DashScope'; readonly name = 'DashScope';
constructor(private readonly config: DashScopeConfig) { constructor(private readonly config: DashScopeProviderConfig) {
super(); super();
} }
isAvailable(): boolean { isAvailable(): boolean {
return !!(this.config.apiKey && this.config.uid && this.config.appId); return true;
// return !!(this.config.apiKey && this.config.uid && this.config.appId);
} }
protected async performSearch( protected async performSearch(
@@ -82,7 +76,7 @@ export class DashScopeProvider extends BaseWebSearchProvider {
): Promise<WebSearchResult> { ): Promise<WebSearchResult> {
const requestBody = { const requestBody = {
rid: '', rid: '',
uid: this.config.uid, uid: this.config.uid!,
scene: this.config.scene || 'dolphin_search_inner_turbo', scene: this.config.scene || 'dolphin_search_inner_turbo',
uq: query, uq: query,
fields: [], fields: [],
@@ -91,7 +85,7 @@ export class DashScopeProvider extends BaseWebSearchProvider {
customConfigInfo: {}, customConfigInfo: {},
headers: { headers: {
__d_head_qto: this.config.timeout || 8000, __d_head_qto: this.config.timeout || 8000,
__d_head_app: this.config.appId, __d_head_app: this.config.appId!,
}, },
}; };
@@ -101,7 +95,7 @@ export class DashScopeProvider extends BaseWebSearchProvider {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: this.config.apiKey, Authorization: this.config.apiKey!,
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
signal, signal,
@@ -110,16 +104,18 @@ export class DashScopeProvider extends BaseWebSearchProvider {
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error( throw new WebSearchError(
`DashScope API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, this.name,
`API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
); );
} }
const data = (await response.json()) as DashScopeSearchResponse; const data = (await response.json()) as DashScopeSearchResponse;
if (data.status !== 0) { if (data.status !== 0) {
throw new Error( throw new WebSearchError(
`DashScope API error: ${data.message || 'Unknown error'}`, this.name,
`API error: ${data.message || 'Unknown error'}`,
); );
} }

View File

@@ -5,7 +5,12 @@
*/ */
import { BaseWebSearchProvider } from '../base-provider.js'; import { BaseWebSearchProvider } from '../base-provider.js';
import type { WebSearchResult, WebSearchResultItem } from '../types.js'; import { WebSearchError } from '../errors.js';
import type {
WebSearchResult,
WebSearchResultItem,
GoogleProviderConfig,
} from '../types.js';
interface GoogleSearchItem { interface GoogleSearchItem {
title: string; title: string;
@@ -23,25 +28,13 @@ interface GoogleSearchResponse {
}; };
} }
/**
* Configuration for Google provider.
*/
export interface GoogleConfig {
apiKey: string;
searchEngineId: string;
maxResults?: number;
safeSearch?: 'off' | 'medium' | 'high';
language?: string;
country?: string;
}
/** /**
* Web search provider using Google Custom Search API. * Web search provider using Google Custom Search API.
*/ */
export class GoogleProvider extends BaseWebSearchProvider { export class GoogleProvider extends BaseWebSearchProvider {
readonly name = 'Google'; readonly name = 'Google';
constructor(private readonly config: GoogleConfig) { constructor(private readonly config: GoogleProviderConfig) {
super(); super();
} }
@@ -54,8 +47,8 @@ export class GoogleProvider extends BaseWebSearchProvider {
signal: AbortSignal, signal: AbortSignal,
): Promise<WebSearchResult> { ): Promise<WebSearchResult> {
const params = new URLSearchParams({ const params = new URLSearchParams({
key: this.config.apiKey, key: this.config.apiKey!,
cx: this.config.searchEngineId, cx: this.config.searchEngineId!,
q: query, q: query,
num: String(this.config.maxResults || 10), num: String(this.config.maxResults || 10),
safe: this.config.safeSearch || 'medium', safe: this.config.safeSearch || 'medium',
@@ -78,8 +71,9 @@ export class GoogleProvider extends BaseWebSearchProvider {
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error( throw new WebSearchError(
`Google Search API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, this.name,
`API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
); );
} }

View File

@@ -5,7 +5,12 @@
*/ */
import { BaseWebSearchProvider } from '../base-provider.js'; import { BaseWebSearchProvider } from '../base-provider.js';
import type { WebSearchResult, WebSearchResultItem } from '../types.js'; import { WebSearchError } from '../errors.js';
import type {
WebSearchResult,
WebSearchResultItem,
TavilyProviderConfig,
} from '../types.js';
interface TavilyResultItem { interface TavilyResultItem {
title: string; title: string;
@@ -21,23 +26,13 @@ interface TavilySearchResponse {
results: TavilyResultItem[]; results: TavilyResultItem[];
} }
/**
* Configuration for Tavily provider.
*/
export interface TavilyConfig {
apiKey: string;
searchDepth?: 'basic' | 'advanced';
maxResults?: number;
includeAnswer?: boolean;
}
/** /**
* Web search provider using Tavily API. * Web search provider using Tavily API.
*/ */
export class TavilyProvider extends BaseWebSearchProvider { export class TavilyProvider extends BaseWebSearchProvider {
readonly name = 'Tavily'; readonly name = 'Tavily';
constructor(private readonly config: TavilyConfig) { constructor(private readonly config: TavilyProviderConfig) {
super(); super();
} }
@@ -66,8 +61,9 @@ export class TavilyProvider extends BaseWebSearchProvider {
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error( throw new WebSearchError(
`Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, this.name,
`API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
); );
} }

View File

@@ -95,7 +95,7 @@ export interface WebSearchConfig {
/** /**
* List of available providers with their configurations. * List of available providers with their configurations.
*/ */
providers: WebSearchProviderConfig[]; provider: WebSearchProviderConfig[];
/** /**
* The default provider to use. * The default provider to use.
@@ -104,16 +104,47 @@ export interface WebSearchConfig {
} }
/** /**
* Base configuration for a web search provider. * Base configuration for Tavily provider.
*/ */
export interface WebSearchProviderConfig { export interface TavilyProviderConfig {
/** type: 'tavily';
* The type of provider. apiKey?: string;
*/ searchDepth?: 'basic' | 'advanced';
type: 'tavily' | 'google' | 'dashscope'; maxResults?: number;
includeAnswer?: boolean;
}
/** /**
* Provider-specific configuration. * Base configuration for Google provider.
*/ */
config?: Record<string, unknown>; export interface GoogleProviderConfig {
type: 'google';
apiKey?: string;
searchEngineId?: string;
maxResults?: number;
safeSearch?: 'off' | 'medium' | 'high';
language?: string;
country?: string;
} }
/**
* Base configuration for DashScope provider.
*/
export interface DashScopeProviderConfig {
type: 'dashscope';
apiKey?: string;
uid?: string;
appId?: string;
maxResults?: number;
scene?: string;
timeout?: number;
}
/**
* Discriminated union type for web search provider configurations.
* This ensures type safety when working with different provider configs.
*/
export type WebSearchProviderConfig =
| TavilyProviderConfig
| GoogleProviderConfig
| DashScopeProviderConfig;

View File

@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Utility functions for web search formatting and processing.
*/
/**
* Format sources into a numbered list with titles and URLs.
* @param sources Array of source objects with title and url
* @returns Formatted source list string
*/
export function formatSources(
sources: Array<{ title: string; url: string }>,
): string {
return sources
.map((s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`)
.join('\n');
}
/**
* Build content string with appended sources section.
* @param content Main content text
* @param sources Array of source objects
* @returns Combined content with sources
*/
export function buildContentWithSources(
content: string,
sources: Array<{ title: string; url: string }>,
): string {
if (!sources.length) return content;
return `${content}\n\nSources:\n${formatSources(sources)}`;
}
/**
* Build a concise summary from top search results.
* @param sources Array of source objects
* @param maxResults Maximum number of results to include
* @returns Concise summary string
*/
export function buildSummary(
sources: Array<{ title: string; url: string }>,
maxResults: number = 3,
): string {
return sources
.slice(0, maxResults)
.map((s, i) => `${i + 1}. ${s.title} - ${s.url}`)
.join('\n');
}