src / image_search / engine / ImageSearchEngine.ts
src / image_search / engine / ImageSearchEngine.ts
import { defaultConfigurationService } from "../../config/ConfigurationService";
import { ProviderError, SearchFailedError } from "../../models/errors";
import { SearchOptions } from "../../types/search";
import { Logger } from "../../utils/Logger";
import { ImageSearchResult, LocalImage } from "../models";
import { BaseImageProvider } from "../providers/BaseImageProvider";
import { ImageProviderRegistry } from "../providers/ImageProviderRegistry";
import { ImageDownloader } from "../utils/ImageDownloader";
export interface ImageSearchEngineOptions {
providers?: BaseImageProvider[];
downloader?: ImageDownloader;
}
/**
* Central Image Search Engine orchestrator.
* Manages provider registration, dynamic configuration resolution, provider fallback execution,
* and passes search results to ImageDownloader for local image caching.
* Mapped from Python `image_search/engine.py` ImageSearchEngine.
*/
export class ImageSearchEngine {
private providers: Map<string, BaseImageProvider> = new Map();
private downloader: ImageDownloader;
private searchCounter: number = 0;
constructor(
providersOrOptions?: BaseImageProvider[] | BaseImageProvider | ImageSearchEngineOptions,
downloader?: ImageDownloader
) {
if (Array.isArray(providersOrOptions)) {
this.downloader = downloader ?? new ImageDownloader();
for (const p of providersOrOptions) {
this.registerProvider(p);
}
} else if (
providersOrOptions &&
typeof providersOrOptions === "object" &&
"providers" in providersOrOptions
) {
const opts = providersOrOptions as ImageSearchEngineOptions;
this.downloader = opts.downloader ?? new ImageDownloader();
if (opts.providers) {
for (const p of opts.providers) {
this.registerProvider(p);
}
}
} else if (providersOrOptions instanceof BaseImageProvider) {
this.downloader = downloader ?? new ImageDownloader();
this.registerProvider(providersOrOptions);
} else {
this.downloader = downloader ?? new ImageDownloader();
}
}
/**
* Register an image provider instance with the engine.
*/
public registerProvider(provider: BaseImageProvider): void {
this.providers.set(provider.providerName.toLowerCase().trim(), provider);
}
/**
* Unregister an image provider by name.
*/
public unregisterProvider(providerName: string): boolean {
return this.providers.delete(providerName.toLowerCase().trim());
}
/**
* Retrieve a registered image provider instance by name.
*/
public getProvider(providerName: string): BaseImageProvider | undefined {
return this.providers.get(providerName.toLowerCase().trim());
}
/**
* List all currently registered image provider names.
*/
public listProviders(): string[] {
return Array.from(this.providers.keys());
}
public getProviders(): BaseImageProvider[] {
return Array.from(this.providers.values());
}
/**
* Execute image search across registered image providers with automatic provider fallback.
*
* @param query Non-empty search query string.
* @param options Search options containing numResults, workingDirectory, and requestId.
* @returns Array of processed LocalImage objects.
* @throws ProviderError if workingDirectory is missing from options or no providers registered.
* @throws SearchFailedError if all image search providers fail during fallback.
*/
public async searchImages(
query: string,
options: SearchOptions = {}
): Promise<LocalImage[]> {
if (!query || !query.trim()) {
return [];
}
this.searchCounter += 1;
const reqId = options.requestId || `IMG-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
const startTime = Date.now();
const requestedProvider = options.provider?.toLowerCase().trim() || "auto";
const configuredDefault = defaultConfigurationService.getImageDefaultProvider() || "auto";
Logger.info("[ImageSearch]", `[${reqId}] Image search execution started for query: "${query}"`);
const workingDir = String(options.workingDirectory || "").trim();
if (!workingDir) {
Logger.error(
"[ImageSearch]",
`Image search failed: missing workingDirectory parameter.`
);
throw new ProviderError("ImageSearchEngine requires a valid workingDirectory in SearchOptions.");
}
const availableProviders = Array.from(this.providers.values());
if (availableProviders.length === 0) {
Logger.error("[ImageSearch]", `[${reqId}] Image search failed: No image search providers are registered.`);
throw new ProviderError("No image search providers are registered.");
}
// 1. Explicit provider parameter requested (e.g. provider = "serpapi")
if (requestedProvider !== "auto") {
const resolved = this.getProvider(requestedProvider);
if (!resolved) {
Logger.error("[ImageSearch]", `[${reqId}] Image search failed: Unknown provider '${requestedProvider}'`);
throw new ProviderError(`Unknown or unregistered image provider: '${options.provider}'`);
}
Logger.info(
"[ImageSearch]",
`[${reqId}] Provider Selection -> Requested: '${requestedProvider}' | Configured Default: '${configuredDefault}' | Mode: Explicit Provider | Selected: image.${requestedProvider}`
);
try {
return await this.executeWithProvider(resolved, query.trim(), options, reqId, workingDir, startTime);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
Logger.error(
"[ImageSearch]",
`[${reqId}] Explicit image provider 'image.${requestedProvider}' failed: ${errMsg}. Fallback disabled in Explicit Provider Mode.`
);
throw err;
}
}
// 2. Explicit provider mode: Configured default provider from Settings (e.g. Default Image Provider = "serpapi")
if (configuredDefault !== "auto") {
const resolved = this.getProvider(configuredDefault);
if (!resolved) {
Logger.error("[ImageSearch]", `[${reqId}] Image search failed: Configured default provider '${configuredDefault}' is unknown or unregistered.`);
throw new ProviderError(`Configured default image provider '${configuredDefault}' is unknown or unregistered.`);
}
Logger.info(
"[ImageSearch]",
`[${reqId}] Provider Selection -> Requested: 'auto' | Configured Default: '${configuredDefault}' | Mode: Explicit Default Provider | Selected: image.${configuredDefault}`
);
try {
return await this.executeWithProvider(resolved, query.trim(), options, reqId, workingDir, startTime);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
Logger.error(
"[ImageSearch]",
`[${reqId}] Configured default image provider 'image.${configuredDefault}' failed: ${errMsg}. Fallback disabled in Explicit Provider Mode.`
);
throw err;
}
}
// 3. Auto Provider Mode: Both requested and default are "auto". Iterate priority list with fallback.
return this.executeFallback(query.trim(), options, reqId, requestedProvider, configuredDefault, workingDir, startTime);
}
/**
* Helper method to execute search using a single target image provider.
*/
private async executeWithProvider(
targetProvider: BaseImageProvider,
query: string,
options: SearchOptions,
reqId: string,
workingDir: string,
startTime: number
): Promise<LocalImage[]> {
const rawResults: ImageSearchResult[] = await targetProvider.searchImages(query, options);
if (!rawResults || rawResults.length === 0) {
const durationMs = Date.now() - startTime;
Logger.info(
"[ImageSearch]",
`[${reqId}] Image search completed. Provider: image.${targetProvider.providerName} | Duration: ${durationMs} ms | Results: 0`
);
return [];
}
const processed = await this.downloader.processResults(
rawResults,
targetProvider.providerName,
workingDir
);
const durationMs = Date.now() - startTime;
Logger.info(
"[ImageSearch]",
`[${reqId}] Image search completed. Provider: image.${targetProvider.providerName} | Duration: ${durationMs} ms | Results: ${processed.length}`
);
return processed;
}
/**
* Iterate through registered image providers in priority order until one succeeds or all fail.
*/
private async executeFallback(
query: string,
options: SearchOptions,
reqId: string,
requestedProvider: string,
configuredDefault: string,
workingDir: string,
startTime: number
): Promise<LocalImage[]> {
const priorityNames = defaultConfigurationService.getImageProviderPriority();
const priorityIds = priorityNames
.map((name) => ImageProviderRegistry.getSpec(name)?.id)
.filter((id) => id !== undefined);
Logger.info(
"[ImageSearch]",
`[${reqId}] Mode: Auto Provider Fallback Chain | Priority IDs: [${priorityIds.join(",")}] | Resolved Order: [${priorityNames.join(", ")}]`
);
const attemptedErrors: Record<string, string> = {};
const orderedProviders: BaseImageProvider[] = [];
for (const name of priorityNames) {
const p = this.getProvider(name);
if (p && !orderedProviders.includes(p)) {
orderedProviders.push(p);
}
}
if (orderedProviders.length === 0) {
const noValMsg = "Auto (Priority Order) cannot run because the configured Provider Priority Order contains no valid providers. Update the Provider Priority Order or select a specific provider.";
Logger.error("[ImageSearch]", `[${reqId}] ${noValMsg}`);
throw new SearchFailedError(noValMsg, {});
}
let lastFailedProvider: string | null = null;
for (const p of orderedProviders) {
const strategy = lastFailedProvider
? `Fallback (after image.${lastFailedProvider} failed)`
: "Auto Fallback Chain";
Logger.info(
"[ImageSearch]",
`[${reqId}] Provider Selection -> Requested: '${requestedProvider}' | Configured Default: '${configuredDefault}' | Strategy: ${strategy} | Selected: image.${p.providerName}`
);
try {
return await this.executeWithProvider(p, query, options, reqId, workingDir, startTime);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
attemptedErrors[p.providerName] = errMsg;
lastFailedProvider = p.providerName;
Logger.warn(
"[ImageSearch]",
`[${reqId}] Image provider 'image.${p.providerName}' exhausted/failed: ${errMsg}. Moving to next provider in configured priority order.`
);
}
}
const errDetails = Object.entries(attemptedErrors)
.map(([p, msg]) => `${p}: ${msg}`)
.join("; ");
Logger.error(
"[ImageSearch]",
`[${reqId}] Image search failed across all providers (${orderedProviders.length} attempted). Details: ${errDetails}`
);
throw new SearchFailedError(
`All image search providers failed. Errors: ${errDetails}`,
attemptedErrors
);
}
}
import { defaultConfigurationService } from "../../config/ConfigurationService";
import { ProviderError, SearchFailedError } from "../../models/errors";
import { SearchOptions } from "../../types/search";
import { Logger } from "../../utils/Logger";
import { ImageSearchResult, LocalImage } from "../models";
import { BaseImageProvider } from "../providers/BaseImageProvider";
import { ImageProviderRegistry } from "../providers/ImageProviderRegistry";
import { ImageDownloader } from "../utils/ImageDownloader";
export interface ImageSearchEngineOptions {
providers?: BaseImageProvider[];
downloader?: ImageDownloader;
}
/**
* Central Image Search Engine orchestrator.
* Manages provider registration, dynamic configuration resolution, provider fallback execution,
* and passes search results to ImageDownloader for local image caching.
* Mapped from Python `image_search/engine.py` ImageSearchEngine.
*/
export class ImageSearchEngine {
private providers: Map<string, BaseImageProvider> = new Map();
private downloader: ImageDownloader;
private searchCounter: number = 0;
constructor(
providersOrOptions?: BaseImageProvider[] | BaseImageProvider | ImageSearchEngineOptions,
downloader?: ImageDownloader
) {
if (Array.isArray(providersOrOptions)) {
this.downloader = downloader ?? new ImageDownloader();
for (const p of providersOrOptions) {
this.registerProvider(p);
}
} else if (
providersOrOptions &&
typeof providersOrOptions === "object" &&
"providers" in providersOrOptions
) {
const opts = providersOrOptions as ImageSearchEngineOptions;
this.downloader = opts.downloader ?? new ImageDownloader();
if (opts.providers) {
for (const p of opts.providers) {
this.registerProvider(p);
}
}
} else if (providersOrOptions instanceof BaseImageProvider) {
this.downloader = downloader ?? new ImageDownloader();
this.registerProvider(providersOrOptions);
} else {
this.downloader = downloader ?? new ImageDownloader();
}
}
/**
* Register an image provider instance with the engine.
*/
public registerProvider(provider: BaseImageProvider): void {
this.providers.set(provider.providerName.toLowerCase().trim(), provider);
}
/**
* Unregister an image provider by name.
*/
public unregisterProvider(providerName: string): boolean {
return this.providers.delete(providerName.toLowerCase().trim());
}
/**
* Retrieve a registered image provider instance by name.
*/
public getProvider(providerName: string): BaseImageProvider | undefined {
return this.providers.get(providerName.toLowerCase().trim());
}
/**
* List all currently registered image provider names.
*/
public listProviders(): string[] {
return Array.from(this.providers.keys());
}
public getProviders(): BaseImageProvider[] {
return Array.from(this.providers.values());
}
/**
* Execute image search across registered image providers with automatic provider fallback.
*
* @param query Non-empty search query string.
* @param options Search options containing numResults, workingDirectory, and requestId.
* @returns Array of processed LocalImage objects.
* @throws ProviderError if workingDirectory is missing from options or no providers registered.
* @throws SearchFailedError if all image search providers fail during fallback.
*/
public async searchImages(
query: string,
options: SearchOptions = {}
): Promise<LocalImage[]> {
if (!query || !query.trim()) {
return [];
}
this.searchCounter += 1;
const reqId = options.requestId || `IMG-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
const startTime = Date.now();
const requestedProvider = options.provider?.toLowerCase().trim() || "auto";
const configuredDefault = defaultConfigurationService.getImageDefaultProvider() || "auto";
Logger.info("[ImageSearch]", `[${reqId}] Image search execution started for query: "${query}"`);
const workingDir = String(options.workingDirectory || "").trim();
if (!workingDir) {
Logger.error(
"[ImageSearch]",
`Image search failed: missing workingDirectory parameter.`
);
throw new ProviderError("ImageSearchEngine requires a valid workingDirectory in SearchOptions.");
}
const availableProviders = Array.from(this.providers.values());
if (availableProviders.length === 0) {
Logger.error("[ImageSearch]", `[${reqId}] Image search failed: No image search providers are registered.`);
throw new ProviderError("No image search providers are registered.");
}
// 1. Explicit provider parameter requested (e.g. provider = "serpapi")
if (requestedProvider !== "auto") {
const resolved = this.getProvider(requestedProvider);
if (!resolved) {
Logger.error("[ImageSearch]", `[${reqId}] Image search failed: Unknown provider '${requestedProvider}'`);
throw new ProviderError(`Unknown or unregistered image provider: '${options.provider}'`);
}
Logger.info(
"[ImageSearch]",
`[${reqId}] Provider Selection -> Requested: '${requestedProvider}' | Configured Default: '${configuredDefault}' | Mode: Explicit Provider | Selected: image.${requestedProvider}`
);
try {
return await this.executeWithProvider(resolved, query.trim(), options, reqId, workingDir, startTime);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
Logger.error(
"[ImageSearch]",
`[${reqId}] Explicit image provider 'image.${requestedProvider}' failed: ${errMsg}. Fallback disabled in Explicit Provider Mode.`
);
throw err;
}
}
// 2. Explicit provider mode: Configured default provider from Settings (e.g. Default Image Provider = "serpapi")
if (configuredDefault !== "auto") {
const resolved = this.getProvider(configuredDefault);
if (!resolved) {
Logger.error("[ImageSearch]", `[${reqId}] Image search failed: Configured default provider '${configuredDefault}' is unknown or unregistered.`);
throw new ProviderError(`Configured default image provider '${configuredDefault}' is unknown or unregistered.`);
}
Logger.info(
"[ImageSearch]",
`[${reqId}] Provider Selection -> Requested: 'auto' | Configured Default: '${configuredDefault}' | Mode: Explicit Default Provider | Selected: image.${configuredDefault}`
);
try {
return await this.executeWithProvider(resolved, query.trim(), options, reqId, workingDir, startTime);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
Logger.error(
"[ImageSearch]",
`[${reqId}] Configured default image provider 'image.${configuredDefault}' failed: ${errMsg}. Fallback disabled in Explicit Provider Mode.`
);
throw err;
}
}
// 3. Auto Provider Mode: Both requested and default are "auto". Iterate priority list with fallback.
return this.executeFallback(query.trim(), options, reqId, requestedProvider, configuredDefault, workingDir, startTime);
}
/**
* Helper method to execute search using a single target image provider.
*/
private async executeWithProvider(
targetProvider: BaseImageProvider,
query: string,
options: SearchOptions,
reqId: string,
workingDir: string,
startTime: number
): Promise<LocalImage[]> {
const rawResults: ImageSearchResult[] = await targetProvider.searchImages(query, options);
if (!rawResults || rawResults.length === 0) {
const durationMs = Date.now() - startTime;
Logger.info(
"[ImageSearch]",
`[${reqId}] Image search completed. Provider: image.${targetProvider.providerName} | Duration: ${durationMs} ms | Results: 0`
);
return [];
}
const processed = await this.downloader.processResults(
rawResults,
targetProvider.providerName,
workingDir
);
const durationMs = Date.now() - startTime;
Logger.info(
"[ImageSearch]",
`[${reqId}] Image search completed. Provider: image.${targetProvider.providerName} | Duration: ${durationMs} ms | Results: ${processed.length}`
);
return processed;
}
/**
* Iterate through registered image providers in priority order until one succeeds or all fail.
*/
private async executeFallback(
query: string,
options: SearchOptions,
reqId: string,
requestedProvider: string,
configuredDefault: string,
workingDir: string,
startTime: number
): Promise<LocalImage[]> {
const priorityNames = defaultConfigurationService.getImageProviderPriority();
const priorityIds = priorityNames
.map((name) => ImageProviderRegistry.getSpec(name)?.id)
.filter((id) => id !== undefined);
Logger.info(
"[ImageSearch]",
`[${reqId}] Mode: Auto Provider Fallback Chain | Priority IDs: [${priorityIds.join(",")}] | Resolved Order: [${priorityNames.join(", ")}]`
);
const attemptedErrors: Record<string, string> = {};
const orderedProviders: BaseImageProvider[] = [];
for (const name of priorityNames) {
const p = this.getProvider(name);
if (p && !orderedProviders.includes(p)) {
orderedProviders.push(p);
}
}
if (orderedProviders.length === 0) {
const noValMsg = "Auto (Priority Order) cannot run because the configured Provider Priority Order contains no valid providers. Update the Provider Priority Order or select a specific provider.";
Logger.error("[ImageSearch]", `[${reqId}] ${noValMsg}`);
throw new SearchFailedError(noValMsg, {});
}
let lastFailedProvider: string | null = null;
for (const p of orderedProviders) {
const strategy = lastFailedProvider
? `Fallback (after image.${lastFailedProvider} failed)`
: "Auto Fallback Chain";
Logger.info(
"[ImageSearch]",
`[${reqId}] Provider Selection -> Requested: '${requestedProvider}' | Configured Default: '${configuredDefault}' | Strategy: ${strategy} | Selected: image.${p.providerName}`
);
try {
return await this.executeWithProvider(p, query, options, reqId, workingDir, startTime);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
attemptedErrors[p.providerName] = errMsg;
lastFailedProvider = p.providerName;
Logger.warn(
"[ImageSearch]",
`[${reqId}] Image provider 'image.${p.providerName}' exhausted/failed: ${errMsg}. Moving to next provider in configured priority order.`
);
}
}
const errDetails = Object.entries(attemptedErrors)
.map(([p, msg]) => `${p}: ${msg}`)
.join("; ");
Logger.error(
"[ImageSearch]",
`[${reqId}] Image search failed across all providers (${orderedProviders.length} attempted). Details: ${errDetails}`
);
throw new SearchFailedError(
`All image search providers failed. Errors: ${errDetails}`,
attemptedErrors
);
}
}