Project Files
src / search / searchEngine.ts
/**
* Draw Things Search Engine
* Implements structured search with exact matches (Volltreffer) and semantic matches (Beifang)
*/
import { existsSync } from 'fs';
import path from 'path';
import type {
GenerationMetadata,
IndexedGeneration,
GenerationMatch,
ImageResult,
DrawThingsSearchResult,
MatchType,
} from '../types';
import {
fuzzyMatch,
normalizePrompt,
tokenize,
type FuzzyMatchResult,
type FuzzyMatchOptions,
} from './fuzzyMatch';
import {
semanticSearch,
semanticMatchToGenerationMatch,
hasEmbeddings,
getEmbeddingStats,
} from './semanticSearch';
import { EmbeddingClient, scoreToSimilarity } from '../embeddings';
import {
parseStructuredQuery,
matchesFilters,
computeFilterScore,
describeFilterMatches,
} from './queryParser';
import type { DtcModelMappingSnapshotV1 } from '../helpers/dtcModelMappingSnapshot';
// ═══════════════════════════════════════════════════════════════
// Search Engine
// ═══════════════════════════════════════════════════════════════
export interface SearchOptions {
/** Maximum number of exact matches to return */
maxExactMatches?: number;
/** Maximum number of semantic matches to return */
maxSemanticMatches?: number;
/** Minimum score for exact matches (0-100) */
minExactScore?: number;
/** Minimum score for semantic matches (0-1) */
minSemanticScore?: number;
/** Include semantic search (requires embeddings) */
includeSemanticSearch?: boolean;
/** Embedding client for query embedding */
embeddingClient?: EmbeddingClient;
/** Fuzzy matching options */
fuzzyOptions?: FuzzyMatchOptions;
/** Weight for semantic vs keyword search (0-1, higher = more semantic) */
semanticWeight?: number;
/** Model mapping snapshot for family-based model matching in structured queries */
snapshot?: DtcModelMappingSnapshotV1;
}
const DEFAULT_OPTIONS: Required<Omit<SearchOptions, 'embeddingClient' | 'fuzzyOptions' | 'snapshot'>> = {
maxExactMatches: 10,
maxSemanticMatches: 5,
minExactScore: 60,
minSemanticScore: 50, // Score 0-100 (converted to similarity internally)
includeSemanticSearch: false,
semanticWeight: 0.6,
};
function getMergeGroupId(match: GenerationMatch): string | null {
const src = match.sourceInfo;
if (!src) return null;
switch (src.type) {
case 'draw_things_project':
return `project:${src.projectFile}`;
case 'generate_image_variant':
return `jsonl:${src.chatId}`;
case 'attachment':
case 'saved_image':
return null;
default:
return null;
}
}
/**
* Round-robin interleave items by source type so that when results are later
* sliced to maxExactMatches, each source type gets proportional representation.
* Preserves order within each source group.
*/
function interleaveBySourceType<T extends { sourceInfo?: { type?: string } }>(items: T[]): T[] {
const groups = new Map<string, T[]>();
for (const item of items) {
const key = item.sourceInfo?.type ?? 'unknown';
const group = groups.get(key);
if (group) group.push(item);
else groups.set(key, [item]);
}
const grouped = [...groups.values()];
const maxLen = Math.max(...grouped.map(g => g.length));
const result: T[] = [];
for (let i = 0; i < maxLen; i++) {
for (const group of grouped) {
if (i < group.length) result.push(group[i]);
}
}
return result;
}
function explodeAlwaysSeparateMatches(matches: GenerationMatch[]): GenerationMatch[] {
const out: GenerationMatch[] = [];
for (const m of matches) {
const srcType = m.sourceInfo?.type;
const mustSeparate = srcType === 'attachment' || srcType === 'saved_image';
if (!mustSeparate || m.imagePaths.length <= 1) {
out.push(m);
continue;
}
for (let i = 0; i < m.imagePaths.length; i++) {
const imagePath = m.imagePaths[i];
const httpPreviewUrl = m.httpPreviewUrls?.[i];
out.push({
...m,
imagePaths: [imagePath],
httpPreviewUrls: httpPreviewUrl !== undefined ? [httpPreviewUrl] : undefined,
});
}
}
return out;
}
/**
* Search through generations for matches
* Now supports IndexedGeneration with embeddings for semantic search
*/
export async function searchGenerations(
query: string,
generations: IndexedGeneration[],
options: SearchOptions = {}
): Promise<DrawThingsSearchResult> {
const startTime = Date.now();
const opts = { ...DEFAULT_OPTIONS, ...options };
// ── Structured Query Parsing ──────────────────────────────────
// Detect labeled metadata fields (Size:, Model:, LoRAs:, etc.) and
// extract them as hard AND filters. The remaining text becomes the
// prompt query for fuzzy / semantic scoring.
// Collect known project filenames for dictionary-based project scanning.
const knownProjectFiles = [...new Set(
generations
.filter(g => g.sourceInfo?.type === 'draw_things_project')
.map(g => (g.sourceInfo as { type: 'draw_things_project'; projectFile: string }).projectFile)
)];
const parsed = parseStructuredQuery(query, opts.snapshot, knownProjectFiles);
let effectiveGenerations = generations;
if (parsed.hasFilters) {
effectiveGenerations = generations.filter(gen =>
matchesFilters(gen, parsed.filters, opts.snapshot)
);
console.log(
`[Search] Structured filters detected — ` +
`${generations.length} → ${effectiveGenerations.length} generations after hard AND`
);
}
const effectiveQuery = parsed.hasFilters ? parsed.promptQuery : query;
const exactMatches: GenerationMatch[] = [];
let semanticMatches: GenerationMatch[] = [];
const trimmedQuery = stripOuterQuotes(effectiveQuery.trim());
const queryFilenameKey = normalizeFilenameKey(trimmedQuery);
const isFilenameQuery = looksLikeImageFilenameQuery(trimmedQuery);
// Normalize query for comparison
const normalizedQuery = normalizePrompt(effectiveQuery);
const queryTerms = tokenize(effectiveQuery);
const queryWordCount = effectiveQuery
.trim()
.split(/\s+/)
.filter(Boolean)
.length;
// ─────────────────────────────────────────────────────────────
// Phase 1: Keyword-based search (Volltreffer)
// ─────────────────────────────────────────────────────────────
console.log(`[Search] Query: "${query}"`);
console.log(`[Search] Searching ${generations.length} generations...`);
// Fast-path: if the query looks like an image filename, do a filename-only lookup.
// This prevents huge noise from generic tokens like ".png" and makes filename hits truly exact.
if (isFilenameQuery && queryFilenameKey) {
const filenameMatches: GenerationMatch[] = [];
for (const gen of effectiveGenerations) {
const match = scoreGenerationByFilename(queryFilenameKey, gen);
if (match && match.matchScore >= opts.minExactScore) {
filenameMatches.push(match);
}
}
filenameMatches.sort((a, b) => b.matchScore - a.matchScore);
const limited = explodeAlwaysSeparateMatches(filenameMatches.slice(0, opts.maxExactMatches));
const validExactMatches = filterMatchesWithExistingImages(limited);
// Beifang for filename lookups: only when we found matches via imagePath
// basenames (on-disk files). When the only match is via attachment
// originalName the user is looking for a specific named file — related
// matches by prompt/model are pure noise ("Schott").
const allMatchesAreAttachmentOriginalName = validExactMatches.every(m => {
if (m.sourceInfo?.type !== 'attachment') return false;
const origKey = normalizeFilenameKey((m.sourceInfo as any).originalName || '');
return origKey === queryFilenameKey;
});
let semanticMatches: GenerationMatch[] = [];
if (!allMatchesAreAttachmentOriginalName) {
const related = buildRelatedMatchesForFilenameTargets(validExactMatches, effectiveGenerations, {
maxRelated: opts.maxSemanticMatches,
});
semanticMatches = filterMatchesWithExistingImages(related);
}
const imageResults = buildImageResults([...validExactMatches, ...semanticMatches]);
const summary = buildSummary(query, validExactMatches, semanticMatches);
return {
query,
totalFound: validExactMatches.length,
searchTimeMs: Date.now() - startTime,
exactMatches: validExactMatches,
semanticMatches,
imageResults,
semanticSearchEnabled: false,
summary,
};
}
// Log embedding stats if available
if (opts.includeSemanticSearch) {
const stats = getEmbeddingStats(effectiveGenerations);
console.log(`[Search] Embeddings: ${stats.withEmbeddings}/${stats.total} (model: ${stats.embeddingModel}, dim: ${stats.dimension})`);
}
// Scoring strategy: filter-only (no prompt text) vs prompt-based
if (parsed.hasFilters && !effectiveQuery.trim()) {
// Pure metadata search — all survivors passed hard filters.
// Score by filter specificity (more categories → higher score).
const score = computeFilterScore(parsed.filters);
const terms = describeFilterMatches(parsed.filters);
// Interleave sources so that the maxExactMatches slice includes entries
// from all source types (e.g. project files alongside JSONL entries).
const diverseGenerations = interleaveBySourceType(effectiveGenerations);
for (const gen of diverseGenerations) {
exactMatches.push(generationToMatch(gen, score, 'exact', terms));
}
} else {
for (const gen of effectiveGenerations) {
const match = scoreGeneration(effectiveQuery, normalizedQuery, queryTerms, gen, opts.fuzzyOptions);
if (match && match.matchScore >= opts.minExactScore) {
exactMatches.push(match);
}
}
}
console.log(`[Search] Found ${exactMatches.length} keyword matches (before filtering)`);
// Sort by score descending
exactMatches.sort((a, b) => b.matchScore - a.matchScore);
// Limit results
const limitedExactMatches = exactMatches.slice(0, opts.maxExactMatches);
const effectiveExactMatches = explodeAlwaysSeparateMatches(limitedExactMatches);
// If we already have high-confidence keyword hits, semantic matches often add noise
// (especially for natural-language phrases due to embedding baseline similarity).
// Users can still force semantic expansion by setting semanticWeight very high.
const hasHighConfidenceExact = effectiveExactMatches.some(m => m.matchScore >= 95);
const bestExactScore = effectiveExactMatches[0]?.matchScore ?? 0;
const hasVeryStrongExact = bestExactScore >= 95;
const isShortQuery = queryWordCount <= 2;
// Suppression heuristic:
// - Long natural-language queries: semantic expansion is often noisy if there are strong exact hits.
// - Short category queries: if we already have a very strong exact hit, prefer precision over recall
// (avoid flooding results with unrelated semantic neighbors).
// Users can still force semantic expansion by setting semanticWeight very high.
const suppressedByExact =
opts.semanticWeight < 0.9 &&
((hasHighConfidenceExact && !isShortQuery) || (isShortQuery && hasVeryStrongExact));
// ─────────────────────────────────────────────────────────────
// Phase 2: Semantic search (Beifang) - using pre-computed embeddings
// ─────────────────────────────────────────────────────────────
const canDoSemanticSearch = opts.includeSemanticSearch &&
opts.embeddingClient &&
hasEmbeddings(effectiveGenerations) &&
!suppressedByExact &&
effectiveQuery.trim().length > 0;
if (canDoSemanticSearch && opts.embeddingClient) {
try {
console.log(`[Search] Running semantic search...`);
// Generate query embedding (embedQuery() adds "query: " prefix for E5 models)
const queryEmbedding = await opts.embeddingClient.embedQuery(effectiveQuery.trim());
// Get prompts of exact matches to exclude from semantic results
const exactMatchPrompts = new Set(effectiveExactMatches.map(m => m.prompt));
// Run semantic search
// Note: minSemanticScore is in 0-100 score scale, convert to similarity for search
let effectiveMinSemanticScore = opts.minSemanticScore;
// For long natural-language queries, require a slightly stronger semantic match to reduce noise.
if (queryWordCount >= 6) {
effectiveMinSemanticScore = Math.min(100, effectiveMinSemanticScore + 10);
}
const minSimilarity = scoreToSimilarity(effectiveMinSemanticScore);
const semanticResults = semanticSearch(
queryEmbedding,
effectiveGenerations,
{
maxResults: opts.maxSemanticMatches,
minSimilarity,
excludePrompts: exactMatchPrompts,
}
);
console.log(`[Search] Found ${semanticResults.length} semantic matches`);
// Convert to GenerationMatch format
semanticMatches = semanticResults.map(semanticMatchToGenerationMatch);
// Merge semantic matches by prompt, but ONLY within the same source scope.
// Allowed merges:
// - Same Draw Things project file
// - Same JSONL tool-call (chatId)
// Never merge:
// - Attachments
// - Manual/saved images
// - Unknown/mixed sources
const mergedByPromptAndSource = new Map<string, GenerationMatch>();
const passthrough: GenerationMatch[] = [];
for (const m of semanticMatches) {
const groupId = getMergeGroupId(m);
if (!groupId) {
passthrough.push(m);
continue;
}
const key = `${m.prompt}||${groupId}`;
const prev = mergedByPromptAndSource.get(key);
if (!prev) {
mergedByPromptAndSource.set(key, m);
continue;
}
const imageToPreview = new Map<string, string | undefined>();
for (let i = 0; i < prev.imagePaths.length; i++) {
imageToPreview.set(prev.imagePaths[i], prev.httpPreviewUrls?.[i]);
}
for (let i = 0; i < m.imagePaths.length; i++) {
const p = m.imagePaths[i];
if (!imageToPreview.has(p)) {
imageToPreview.set(p, m.httpPreviewUrls?.[i]);
}
}
const mergedImagePaths = [...imageToPreview.keys()];
const mergedPreviewUrls = mergedImagePaths.map(p => imageToPreview.get(p)).filter(u => u !== undefined) as string[];
const merged: GenerationMatch = {
...(prev.matchScore >= m.matchScore ? prev : m),
matchScore: Math.max(prev.matchScore, m.matchScore),
imagePaths: mergedImagePaths,
httpPreviewUrls: mergedPreviewUrls.length > 0
? mergedImagePaths.map(p => imageToPreview.get(p)) as string[]
: undefined,
};
mergedByPromptAndSource.set(key, merged);
}
semanticMatches = [
...mergedByPromptAndSource.values(),
...passthrough,
].sort((a, b) => b.matchScore - a.matchScore);
semanticMatches = explodeAlwaysSeparateMatches(semanticMatches);
} catch (e) {
console.warn('[Search] Semantic search failed:', e);
}
} else if (opts.includeSemanticSearch) {
console.log(
`[Search] Semantic search skipped (client: ${!!opts.embeddingClient}, hasEmbeddings: ${hasEmbeddings(effectiveGenerations)}, suppressedByExact: ${suppressedByExact})`
);
}
// ─────────────────────────────────────────────────────────────
// Phase 3: Filter dead links
// ─────────────────────────────────────────────────────────────
const validExactMatches = filterMatchesWithExistingImages(effectiveExactMatches);
const validSemanticMatches = filterMatchesWithExistingImages(semanticMatches);
// ─────────────────────────────────────────────────────────────
// Build result
// ─────────────────────────────────────────────────────────────
const allMatches = [...validExactMatches, ...validSemanticMatches];
const imageResults = buildImageResults(allMatches);
const summary = buildSummary(query, validExactMatches, validSemanticMatches);
return {
query,
totalFound: validExactMatches.length + validSemanticMatches.length,
searchTimeMs: Date.now() - startTime,
exactMatches: validExactMatches,
semanticMatches: validSemanticMatches,
imageResults,
semanticSearchEnabled: canDoSemanticSearch ?? false,
summary,
};
}
// ═══════════════════════════════════════════════════════════════
// Scoring Functions
// ═══════════════════════════════════════════════════════════════
const STOP_FILENAME_TERMS = new Set([
'png',
'jpg',
'jpeg',
'webp',
'gif',
'tif',
'tiff',
'bmp',
'heic',
'heif',
]);
function safeBasename(maybePath: string): string {
// Handle users pasting full paths or Windows-style paths.
const normalized = maybePath.replace(/\\/g, '/');
try {
return path.basename(normalized);
} catch {
const parts = normalized.split('/').filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
}
function stripOuterQuotes(s: string): string {
if (s.length >= 2) {
const first = s[0];
const last = s[s.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return s.slice(1, -1);
}
}
return s;
}
function normalizeFilenameKey(name: string): string {
const n = stripOuterQuotes(name.trim()).replace(/\\/g, '/');
const base = safeBasename(n);
// NFKC helps with weird unicode variants; lowercasing makes matching deterministic.
return base.trim().toLowerCase().normalize('NFKC');
}
function looksLikeImageFilenameQuery(query: string): boolean {
const key = normalizeFilenameKey(query);
const dot = key.lastIndexOf('.');
if (dot <= 0 || dot === key.length - 1) return false;
const ext = key.slice(dot + 1);
return STOP_FILENAME_TERMS.has(ext);
}
function scoreGenerationByFilename(queryFilenameKey: string, gen: GenerationMetadata): GenerationMatch | null {
const basenames = (gen.imagePaths || [])
.map(p => normalizeFilenameKey(p))
.filter(Boolean);
const attachmentOriginalName = (gen.sourceInfo && gen.sourceInfo.type === 'attachment')
? normalizeFilenameKey(gen.sourceInfo.originalName)
: '';
const isExact =
(!!queryFilenameKey && basenames.includes(queryFilenameKey)) ||
(!!queryFilenameKey && attachmentOriginalName === queryFilenameKey);
if (isExact) {
return generationToMatch(gen, 100, 'exact', [queryFilenameKey]);
}
// If user pasted a filename with extension, we only allow very tight partial matching.
// (prevents noise; user can always search by prompt/model for broader recall)
if (queryFilenameKey.length >= 8) {
const hitInBasename = basenames.some(b => b.includes(queryFilenameKey));
const hitInOriginalName = attachmentOriginalName ? attachmentOriginalName.includes(queryFilenameKey) : false;
if (hitInBasename || hitInOriginalName) {
return generationToMatch(gen, 95, 'partial', [queryFilenameKey]);
}
}
return null;
}
function matchKey(m: GenerationMatch): string {
const src = m.sourceInfo ? JSON.stringify(m.sourceInfo) : '';
const p = (m.imagePaths || []).join('|');
return `${src}::${p}`;
}
function parseTimestampMs(ts?: string): number {
if (!ts) return 0;
const ms = Date.parse(ts);
return Number.isFinite(ms) ? ms : 0;
}
function buildRelatedMatchesForFilenameTargets(
targets: GenerationMatch[],
generations: IndexedGeneration[],
opts: { maxRelated: number }
): GenerationMatch[] {
const maxRelated = Math.max(0, opts.maxRelated ?? 0);
if (maxRelated === 0 || targets.length === 0) return [];
const targetKeys = new Set(targets.map(matchKey));
const targetPromptKeys = new Set(
targets
.map(t => normalizePrompt(t.prompt || ''))
.filter(Boolean)
);
const targetModels = new Set(
targets
.map(t => (t.model || '').trim())
.filter(Boolean)
);
const samePrompt: GenerationMatch[] = [];
const sameModel: GenerationMatch[] = [];
for (const gen of generations) {
const asMatch = generationToMatch(gen, 0, 'semantic');
if (targetKeys.has(matchKey(asMatch))) continue;
const genPromptKey = normalizePrompt(gen.prompt || '');
if (genPromptKey && targetPromptKeys.has(genPromptKey)) {
samePrompt.push(
generationToMatch(gen, 92, 'semantic', ['same_prompt'])
);
continue;
}
const model = (gen.model || '').trim();
if (model && targetModels.has(model)) {
sameModel.push(
generationToMatch(gen, 78, 'semantic', ['same_model'])
);
}
}
// Prefer prompt-equality beifang, then a small number of same-model results.
// Sort by timestamp desc as a reasonable default.
samePrompt.sort((a, b) => parseTimestampMs(b.timestamp) - parseTimestampMs(a.timestamp));
sameModel.sort((a, b) => parseTimestampMs(b.timestamp) - parseTimestampMs(a.timestamp));
const promptQuota = Math.min(maxRelated, Math.max(3, Math.floor(maxRelated * 0.7)));
const modelQuota = Math.max(0, maxRelated - promptQuota);
const out = [
...samePrompt.slice(0, promptQuota),
...sameModel.slice(0, modelQuota),
];
return explodeAlwaysSeparateMatches(out);
}
/**
* Score a generation against a query
*/
function scoreGeneration(
originalQuery: string,
normalizedQuery: string,
queryTerms: string[],
gen: GenerationMetadata,
fuzzyOptions?: FuzzyMatchOptions
): GenerationMatch | null {
const prompt = gen.prompt || '';
const normalizedPrompt = normalizePrompt(prompt);
const queryLowerRaw = stripOuterQuotes(originalQuery.trim().toLowerCase());
const queryBasename = safeBasename(queryLowerRaw);
// Try fuzzy match on full prompt
const promptMatch = fuzzyMatch(originalQuery, prompt, fuzzyOptions);
// Also try on normalized version
const normalizedMatch = fuzzyMatch(normalizedQuery, normalizedPrompt, fuzzyOptions);
// Use better score
const bestMatch = promptMatch.score >= normalizedMatch.score ? promptMatch : normalizedMatch;
// Additional scoring factors
let bonusScore = 0;
const matchedTerms = new Set(bestMatch.matchedTerms);
// Source-type hint matching (ComfyUI / Draw Things)
// Lets users search for "ComfyUI images" or "Draw Things images".
const imageType = (gen.sourceInfo && (gen.sourceInfo as any).imageType)
? String((gen.sourceInfo as any).imageType)
: '';
const imageTypeLower = imageType.toLowerCase();
// Normalize common query forms.
const wantsComfy = queryTerms.some(t => t === 'comfyui' || t === 'comfy');
const wantsDrawThings =
queryTerms.some(t => t === 'drawthings' || t === 'draw-things') ||
(queryTerms.includes('draw') && queryTerms.includes('things'));
if (wantsComfy && imageTypeLower === 'comfyui') {
bonusScore += 25;
matchedTerms.add('comfyui');
}
if (wantsDrawThings && imageTypeLower === 'draw things') {
bonusScore += 25;
matchedTerms.add('draw things');
}
// File name / attachment name matching
// This makes queries like "ComfyUI_00011_.png" work even when the prompt doesn't contain the filename.
const basenames = (gen.imagePaths || [])
.map(p => {
try {
return path.basename(p).toLowerCase();
} catch {
return '';
}
})
.filter(Boolean);
const attachmentOriginalName = (gen.sourceInfo && gen.sourceInfo.type === 'attachment')
? gen.sourceInfo.originalName.toLowerCase()
: '';
// If the query is an exact filename (or full path), treat it as an exact hit.
// This also activates the existing semantic-suppression heuristic (>=95 score).
const isExactFilenameHit =
(!!queryBasename && basenames.includes(queryBasename)) ||
(!!queryBasename && attachmentOriginalName === queryBasename);
if (isExactFilenameHit) {
return generationToMatch(gen, 100, 'exact', [queryBasename]);
}
let fileHitTerms = 0;
const queryTermsForFilename = queryTerms.filter(
term => !!term && term.length >= 3 && !STOP_FILENAME_TERMS.has(term)
);
for (const term of queryTermsForFilename) {
const hitInBasename = basenames.some(b => b.includes(term));
const hitInOriginalName = attachmentOriginalName ? attachmentOriginalName.includes(term) : false;
if (hitInBasename || hitInOriginalName) {
fileHitTerms++;
matchedTerms.add(term);
}
}
// Boost filename matches above typical minMatchScore (e.g. 70)
if (fileHitTerms > 0) {
bonusScore += 80 + Math.min(20, (fileHitTerms - 1) * 5);
}
// Check model name
if (gen.model) {
const modelLower = gen.model.toLowerCase();
for (const term of queryTerms) {
if (modelLower.includes(term)) {
bonusScore += 10;
matchedTerms.add(term);
}
}
}
// Check LoRAs
if (gen.loras) {
const loraString = Array.isArray(gen.loras)
? gen.loras.map(l => typeof l === 'string' ? l : l.model).join(' ').toLowerCase()
: '';
for (const term of queryTerms) {
if (loraString.includes(term)) {
bonusScore += 10;
matchedTerms.add(term);
}
}
}
// Check video keyword
const isVideo = typeof gen.numFrames === 'number' && gen.numFrames > 1;
if (isVideo) {
const wantsVideo = queryTerms.some(t =>
t === 'video' || t === 'animation' || t === 'animated' || t === 'clip' || t === 'frames'
);
if (wantsVideo) {
bonusScore += 25;
matchedTerms.add('video');
}
}
const finalScore = Math.min(100, bestMatch.score + bonusScore);
if (finalScore === 0) {
return null;
}
return generationToMatch(
gen,
finalScore,
bestMatch.type === 'none' ? 'partial' : bestMatch.type,
Array.from(matchedTerms)
);
}
/**
* Convert GenerationMetadata to GenerationMatch
*/
function generationToMatch(
gen: GenerationMetadata,
score: number,
matchType: MatchType,
matchedTerms?: string[]
): GenerationMatch {
// Normalize LoRAs to string array
const loras = gen.loras?.map(l => typeof l === 'string' ? l : l.model);
return {
prompt: gen.prompt,
negativePrompt: gen.negativePrompt,
model: gen.model,
loras,
seed: gen.seed,
steps: gen.steps,
cfgScale: gen.cfgScale,
width: gen.width,
height: gen.height,
imagePaths: gen.imagePaths || [],
httpPreviewUrls: gen.httpPreviewUrls,
sourceInfo: gen.sourceInfo,
timestamp: gen.timestamp,
matchScore: score,
matchType,
matchedTerms,
numFrames: typeof gen.numFrames === 'number' && gen.numFrames > 1 ? gen.numFrames : undefined,
};
}
// ═══════════════════════════════════════════════════════════════
// Result Building
// ═══════════════════════════════════════════════════════════════
/**
* Check if an image path is a project:// URI
* These are extracted on-demand, so we trust they exist
*/
function isProjectUri(path: string): boolean {
return path.startsWith('project://');
}
/**
* Filter out image paths that no longer exist
* project:// URIs are always considered valid (on-demand extraction)
*/
function filterExistingImages(imagePaths: string[]): string[] {
return imagePaths.filter(p => {
// project:// URIs are always valid - they're extracted on-demand
if (isProjectUri(p)) {
return true;
}
try {
return existsSync(p);
} catch {
return false;
}
});
}
/**
* Filter matches to only include those with existing images
* No existing images = no match (full filter)
*/
function filterMatchesWithExistingImages(matches: GenerationMatch[]): GenerationMatch[] {
console.log(`[Filter] Input: ${matches.length} matches`);
const filtered: GenerationMatch[] = [];
let totalFiltered = 0;
for (const match of matches) {
const originalCount = match.imagePaths.length;
const existingPaths = filterExistingImages(match.imagePaths);
const removedCount = originalCount - existingPaths.length;
if (removedCount > 0) {
const removed = match.imagePaths.filter(p => !existingPaths.includes(p));
console.log(`[Filter] Removed ${removedCount} dead links:`);
for (const p of removed) {
console.log(`[Filter] - ${p}`);
}
totalFiltered += removedCount;
}
// Only include match if at least one image still exists
if (existingPaths.length > 0) {
filtered.push({
...match,
imagePaths: existingPaths,
});
} else if (originalCount > 0) {
console.log(`[Filter] Match dropped (all ${originalCount} images gone): "${match.prompt.slice(0, 50)}..."`);
}
}
console.log(`[Filter] Output: ${filtered.length} matches (removed ${totalFiltered} dead links)`);
return filtered;
}
/**
* Build flat image results for UI display
* Only includes images that exist on disk
*/
function buildImageResults(matches: GenerationMatch[]): ImageResult[] {
const results: ImageResult[] = [];
for (const match of matches) {
// imagePaths are already filtered at this point
for (let i = 0; i < match.imagePaths.length; i++) {
const imagePath = match.imagePaths[i];
const httpPreviewUrl = match.httpPreviewUrls?.[i];
results.push({
path: imagePath,
httpPreviewUrl,
prompt: match.prompt,
model: match.model,
matchType: match.matchType,
score: match.matchScore,
timestamp: match.timestamp,
});
}
}
return results;
}
/**
* Build human-readable summary for chat context
*/
function buildSummary(
query: string,
exactMatches: GenerationMatch[],
semanticMatches: GenerationMatch[]
): string {
const parts: string[] = [];
if (exactMatches.length === 0 && semanticMatches.length === 0) {
return `No generations found matching "${query}".`;
}
if (exactMatches.length > 0) {
const bestMatch = exactMatches[0];
const matchTypeDesc = bestMatch.matchType === 'exact'
? 'exact match'
: bestMatch.matchType === 'fuzzy'
? 'close match'
: 'partial match';
parts.push(
`Found ${exactMatches.length} generation${exactMatches.length > 1 ? 's' : ''} ` +
`matching "${query}" (best: ${matchTypeDesc}, score ${bestMatch.matchScore}).`
);
// List top matches
const topMatches = exactMatches.slice(0, 3);
for (const m of topMatches) {
const promptPreview = m.prompt.length > 60
? m.prompt.slice(0, 60) + '...'
: m.prompt;
parts.push(` • "${promptPreview}" (${m.model}, ${m.imagePaths.length} image${m.imagePaths.length > 1 ? 's' : ''})`);
}
if (exactMatches.length > 3) {
parts.push(` ... and ${exactMatches.length - 3} more.`);
}
}
if (semanticMatches.length > 0) {
parts.push('');
parts.push(`Also found ${semanticMatches.length} semantically related generation${semanticMatches.length > 1 ? 's' : ''}:`);
for (const m of semanticMatches.slice(0, 2)) {
const promptPreview = m.prompt.length > 50
? m.prompt.slice(0, 50) + '...'
: m.prompt;
parts.push(` • "${promptPreview}"`);
}
}
return parts.join('\n');
}
// ═══════════════════════════════════════════════════════════════
// Export for draw-things-chat
// ═══════════════════════════════════════════════════════════════
export { type DrawThingsSearchResult, type GenerationMatch, type ImageResult };