Improve local service checks and web search tooling
This commit is contained in:
@@ -776,6 +776,7 @@ const webSearchSchema = z.object({
|
||||
provider: z.enum(['brave', 'searxng']).default('brave'),
|
||||
api_key: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
fallback_endpoint: z.string().optional(),
|
||||
max_results: z.number().min(1).max(20).default(5),
|
||||
}).default({});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export function initTools(deps: ToolsDeps): ToolsResult {
|
||||
provider: config.web_search.provider,
|
||||
apiKey: config.web_search.api_key,
|
||||
endpoint: config.web_search.endpoint,
|
||||
fallbackEndpoint: config.web_search.fallback_endpoint,
|
||||
maxResults: config.web_search.max_results,
|
||||
})) {
|
||||
toolRegistry.register(tool);
|
||||
|
||||
@@ -7,6 +7,7 @@ function createConfig(params?: {
|
||||
audioEnabled?: boolean;
|
||||
webSearchProvider?: 'brave' | 'searxng';
|
||||
webSearchApiKey?: string;
|
||||
webSearchEndpoint?: string;
|
||||
}): Config {
|
||||
return {
|
||||
audio: {
|
||||
@@ -19,7 +20,7 @@ function createConfig(params?: {
|
||||
web_search: {
|
||||
provider: params?.webSearchProvider ?? 'brave',
|
||||
api_key: params?.webSearchApiKey,
|
||||
endpoint: undefined,
|
||||
endpoint: params?.webSearchEndpoint,
|
||||
max_results: 5,
|
||||
},
|
||||
} as unknown as Config;
|
||||
@@ -201,6 +202,46 @@ describe('listDockerDependencyStatuses', () => {
|
||||
expect(statuses[0]?.id).toBe('whisper');
|
||||
expect(statuses[0]?.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('labels searxng dependency and marks configured when searxng endpoint is set', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args.join(' ') === 'config --profiles') {
|
||||
return { stdout: 'search\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile search config --services') {
|
||||
return { stdout: 'flynn\nsearxng\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile search ps --all --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile search ps searxng --format json') {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'searxng',
|
||||
Service: 'searxng',
|
||||
State: 'running',
|
||||
Status: 'Up 5 minutes',
|
||||
}]),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const statuses = await listDockerDependencyStatuses(
|
||||
createConfig({ webSearchProvider: 'searxng', webSearchEndpoint: 'http://searxng:8080' }),
|
||||
runner,
|
||||
);
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]).toMatchObject({
|
||||
id: 'searxng',
|
||||
name: 'SearXNG',
|
||||
service: 'searxng',
|
||||
configured: true,
|
||||
state: 'running',
|
||||
statusText: 'Up 5 minutes',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('controlDockerDependency', () => {
|
||||
|
||||
@@ -55,6 +55,7 @@ const FLYNN_SERVICE = 'flynn';
|
||||
const WHISPER_SERVICE = 'whisper-server';
|
||||
const WHISPER_DEPENDENCY_ID = 'whisper';
|
||||
const BRAVE_SEARCH_SERVICE = 'brave-search';
|
||||
const SEARXNG_SERVICE = 'searxng';
|
||||
|
||||
function runCompose(args: string[], timeout: number): Promise<DockerComposeResult> {
|
||||
return execFile('docker', ['compose', '-f', COMPOSE_FILE, ...args], {
|
||||
@@ -212,6 +213,12 @@ function isBraveSearchConfigured(config: Config): boolean {
|
||||
return typeof apiKey === 'string' && apiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
function isSearxngConfigured(config: Config): boolean {
|
||||
if (config.web_search.provider !== 'searxng') {return false;}
|
||||
const endpoint = config.web_search.endpoint;
|
||||
return typeof endpoint === 'string' && endpoint.trim().length > 0;
|
||||
}
|
||||
|
||||
function toDependencyId(service: string): DockerDependencyId {
|
||||
if (service === WHISPER_SERVICE) {
|
||||
return WHISPER_DEPENDENCY_ID;
|
||||
@@ -247,6 +254,14 @@ function describeDependency(service: string, config: Config): DockerDependencyDe
|
||||
configured: isBraveSearchConfigured(config),
|
||||
};
|
||||
}
|
||||
if (service === SEARXNG_SERVICE) {
|
||||
return {
|
||||
id: SEARXNG_SERVICE,
|
||||
name: 'SearXNG',
|
||||
service,
|
||||
configured: isSearxngConfigured(config),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: toDependencyId(service),
|
||||
name: toDependencyName(service),
|
||||
|
||||
@@ -97,6 +97,7 @@ export function discoverServices(
|
||||
metadata: {
|
||||
provider: config.web_search?.provider ?? 'brave',
|
||||
endpoint: config.web_search?.endpoint,
|
||||
fallback_endpoint: config.web_search?.fallback_endpoint,
|
||||
max_results: config.web_search?.max_results,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,6 +69,12 @@ const searxngConfig: WebSearchConfig = {
|
||||
endpoint: 'http://searxng:8080',
|
||||
};
|
||||
|
||||
const searxngWithFallbackConfig: WebSearchConfig = {
|
||||
provider: 'searxng',
|
||||
endpoint: 'http://searxng:8080',
|
||||
fallbackEndpoint: 'https://searxng.homelab.local',
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
@@ -216,6 +222,47 @@ describe('web.search', () => {
|
||||
expect(result.error).toBe('SearXNG endpoint not configured');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to backup endpoint when primary endpoint fails', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error('connect ECONNREFUSED'))
|
||||
.mockResolvedValueOnce(mockJsonResponse(searxngResults));
|
||||
|
||||
const tool = createWebSearchTool(searxngWithFallbackConfig);
|
||||
const result = await tool.execute({ query: 'test query' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('http://searxng:8080/search');
|
||||
expect(mockFetch.mock.calls[1][0]).toContain('https://searxng.homelab.local/search');
|
||||
});
|
||||
|
||||
it('returns combined error when primary and fallback both fail', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error('primary timeout'))
|
||||
.mockRejectedValueOnce(new Error('fallback timeout'));
|
||||
|
||||
const tool = createWebSearchTool(searxngWithFallbackConfig);
|
||||
const result = await tool.execute({ query: 'test query' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('SearXNG primary failed');
|
||||
expect(result.error).toContain('fallback failed');
|
||||
});
|
||||
|
||||
it('allows fallback-only endpoint configuration', async () => {
|
||||
mockFetch.mockResolvedValue(mockJsonResponse(searxngResults));
|
||||
|
||||
const tool = createWebSearchTool({
|
||||
provider: 'searxng',
|
||||
fallbackEndpoint: 'https://searxng.homelab.local',
|
||||
});
|
||||
|
||||
const result = await tool.execute({ query: 'test query' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('https://searxng.homelab.local/search');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Count parameter ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface WebSearchConfig {
|
||||
apiKey?: string;
|
||||
/** Required for SearXNG (e.g. 'http://searxng:8080'). */
|
||||
endpoint?: string;
|
||||
/** Optional fallback SearXNG endpoint (for example a homelab instance). */
|
||||
fallbackEndpoint?: string;
|
||||
/** Maximum number of results to return (default: 5). */
|
||||
maxResults?: number;
|
||||
}
|
||||
@@ -191,6 +193,66 @@ async function searchSearxng(
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeEndpoint(endpoint: string | undefined): string | null {
|
||||
if (typeof endpoint !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = endpoint.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveSearxngEndpoints(config: WebSearchConfig): string[] {
|
||||
const endpoints: string[] = [];
|
||||
const primary = normalizeEndpoint(config.endpoint);
|
||||
const fallback = normalizeEndpoint(config.fallbackEndpoint);
|
||||
|
||||
if (primary) {
|
||||
endpoints.push(primary);
|
||||
}
|
||||
if (fallback && fallback !== primary) {
|
||||
endpoints.push(fallback);
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function searchSearxngWithFallback(
|
||||
query: string,
|
||||
count: number,
|
||||
config: WebSearchConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SearchResult[]> {
|
||||
const endpoints = resolveSearxngEndpoints(config);
|
||||
if (endpoints.length === 0) {
|
||||
throw new Error('SearXNG endpoint not configured');
|
||||
}
|
||||
|
||||
if (endpoints.length === 1) {
|
||||
return searchSearxng(query, count, endpoints[0] as string, signal);
|
||||
}
|
||||
|
||||
const [primary, fallback] = endpoints;
|
||||
try {
|
||||
return await searchSearxng(query, count, primary as string, signal);
|
||||
} catch (primaryError) {
|
||||
if (signal?.aborted) {
|
||||
throw primaryError;
|
||||
}
|
||||
|
||||
try {
|
||||
return await searchSearxng(query, count, fallback as string, signal);
|
||||
} catch (fallbackError) {
|
||||
throw new Error(
|
||||
`SearXNG primary failed: ${normalizeErrorMessage(primaryError)}; fallback failed: ${normalizeErrorMessage(fallbackError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolError(error: unknown): ToolResult {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
|
||||
return { success: false, output: '', error: 'Operation aborted' };
|
||||
@@ -274,14 +336,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
|
||||
results = await searchBraveWeb(braveArgs, config.apiKey, context?.signal);
|
||||
} else {
|
||||
// SearXNG provider
|
||||
if (!config.endpoint) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'SearXNG endpoint not configured',
|
||||
};
|
||||
}
|
||||
results = await searchSearxng(args.query, count, config.endpoint, context?.signal);
|
||||
results = await searchSearxngWithFallback(args.query, count, config, context?.signal);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user