src / tools / index_image.ts
src / tools / index_image.ts
/**
* index_image Tool
* Searches Draw Things generation history and returns structured results
*/
import { tool, type Tool, type ToolsProviderController, type ToolCallContext } from "@lmstudio/sdk";
// @ts-ignore — zod/lib re-export chain breaks with NodeNext moduleResolution; runtime is fine
import { z } from "zod";
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
import {
syncAttachmentsToState,
appendPictures,
readState,
writeStateAtomic,
generatePreview,
getDefaultPreviewOptions,
getSelfPluginIdentifier,
isProjectUri,
resolveProjectUri,
previewFilenameFrom,
} from "../core-bundle.mjs";
import { resolveMediaQueries } from "../media/mediaResolver.js";
import { searchGenerations } from "../search/searchEngine.js";
import { configSchematics } from "../config.js";
import { EmbeddingClient } from "../embeddings/index.js";
import { checkEmbeddingCapability, type EmbeddingPrimerResult } from "../helpers/embeddingCapabilityPrimer.js";
import { formatToolMetaBlock } from "../helpers/pluginMeta.js";
import {
parseDtcQuery,
maybeFormatModelForConsumerFromSnapshot,
maybeGetModelRewriteHintsFromSnapshot,
type DtcModelMappingSnapshotV1,
} from "../helpers/dtcModelMappingSnapshot.js";
import type { DrawThingsSearchResult } from "../types.js";
// Naming scheme ported 1:1 from draw-things-chat/src/services/toolResultHarvester.ts
// (materializePictureCandidates) — required for cross-plugin dedup of imported pictures.
function shortHexSha256(input: string, chars = 12): string {
const h = crypto.createHash("sha256").update(input).digest("hex");
return h.slice(0, Math.max(4, Math.min(64, chars)));
}
function sanitizeLocalFilename(name: string, fallback: string): string {
const cleaned = path.basename(String(name || "").trim())
.replace(/[\x00-\x1f\x7f]/g, "")
.replace(/:/g, "-")
.trim();
return cleaned || fallback;
}
function basenameWithFallbackExtension(name: string, fallbackName: string, fallbackExt: string): string {
const base = sanitizeLocalFilename(name, fallbackName);
return path.extname(base) ? base : `${base}${fallbackExt}`;
}
function projectPictureBaseName(uri: string): string {
const withoutScheme = uri.startsWith("project://") ? uri.slice("project://".length) : uri;
const hashIndex = withoutScheme.lastIndexOf("#");
const projectPath = hashIndex !== -1 ? withoutScheme.slice(0, hashIndex) : withoutScheme;
const previewIndex = hashIndex !== -1 ? withoutScheme.slice(hashIndex + 1).trim() : shortHexSha256(uri, 8);
const projectFile = sanitizeLocalFilename(projectPath, "project.sqlite3");
const safePreviewIndex = previewIndex.replace(/[^A-Za-z0-9_.-]+/g, "-") || shortHexSha256(uri, 8);
return `${projectFile}#${safePreviewIndex}.png`;
}
const IMAGE_FILENAME_EXTS = new Set(["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "bmp", "heic", "heif"]);
function looksLikeImageFilenameQuery(q: string): boolean {
const trimmed = q.trim().replace(/^['"]/,'').replace(/['"']$/,'');
const normalized = trimmed.replace(/\\/g, "/");
const base = normalized.split("/").pop() ?? normalized;
const dot = base.lastIndexOf(".");
if (dot <= 0 || dot === base.length - 1) return false;
const ext = base.slice(dot + 1).toLowerCase();
return IMAGE_FILENAME_EXTS.has(ext);
}
function looksLikeProjectFilenameQuery(q: string): boolean {
const trimmed = q.trim().replace(/^['"]/,'').replace(/['"']$/,'');
const normalized = trimmed.replace(/\\/g, "/");
const base = normalized.split("/").pop() ?? normalized;
return base.toLowerCase().endsWith('.sqlite3');
}
// Cached embedding client (reused across tool calls)
let embeddingClient: EmbeddingClient | null = null;
// Cached embedding capability check (refreshed periodically)
let cachedCapabilityResult: EmbeddingPrimerResult | null = null;
let lastCapabilityCheckMs = 0;
const CAPABILITY_CHECK_INTERVAL_MS = 30_000; // Re-check every 30s
// Tool parameters schema
const SearchParamsSchema = {
query: z.string().describe("Search query - filename, prompt text, model name, LoRA name, or keywords"),
};
export function createSearchGenerationsTool(ctl: ToolsProviderController): Tool {
return tool({
name: "index_image",
description: `Search Draw Things generation history by text metadata.
Use this tool for prompt, filename, model, LoRA, source, project, and timestamp search:
- Media references: aN (attachment), vN (variant), iN (image), pN (picture)
- File names: attachments, indexed image files, logged outputs, or project-backed images
- Draw Things project filename: returns all generations from that project in chronological order
- Prompt text and keywords, including cross-language semantic search when embeddings are available
Returns:
- Exact/fuzzy matches: direct filename, prompt, model, LoRA, project, or metadata hits
- Semantic matches: text-embedding matches when enabled, excluding project filename queries
- Image paths for each matching generation
Example queries:
- "a1" - searches by the first attachment's filename/original name
- "my-project.sqlite3" - returns all generations from that Draw Things project
- "flux cyberpunk portrait" - matches prompt/model metadata
- "Sonnenuntergang" - can match related text such as sunset via semantic search
${formatToolMetaBlock()}`,
parameters: SearchParamsSchema,
implementation: async (args: { query: string }, ctx: ToolCallContext) => {
try {
// Sync attachments from conversation.json → chat_media_state.json (non-fatal).
// Must run before query rewrite so tryBuildMediaIndex can resolve a1 → originalName.
try {
const workingDir = ctl.getWorkingDirectory();
if (typeof workingDir === "string" && workingDir.trim().length > 0) {
await syncAttachmentsToState(workingDir, false, Number.MAX_SAFE_INTEGER);
}
} catch (syncErr: any) {
console.warn(
"[index_image] attachment sync failed (non-fatal):",
syncErr?.message ?? syncErr,
);
}
ctx.status("Starting search...");
const { query: parsedQuery, enableModelRewrite, snapshot } = parseDtcQuery(args.query);
if (enableModelRewrite) {
ctx.status("Model rewrite enabled; snapshot received.");
}
// Rewrite index-notation tokens (a1, v2, i3, p4) to real filenames.
// syncAttachmentsToState above ensures chat_media_state.json exists.
let query = parsedQuery;
if (/\b[avip]\d+\b/i.test(query)) {
try {
const workingDir = ctl.getWorkingDirectory();
if (typeof workingDir === "string" && workingDir.trim()) {
const resolved = await resolveMediaQueries(workingDir, query);
if (resolved.rewrittenQuery !== query) {
console.info(`[index_image] Query rewrite: "${query}" → "${resolved.rewrittenQuery}"`);
query = resolved.rewrittenQuery;
}
}
} catch {
// non-fatal
}
}
// Get indexed generations with progress feedback
const { indexGenerations } = await import("../indexer.js");
const generations = await indexGenerations(
ctl,
false, // use cache if available
(msg: string) => ctx.status(msg)
);
ctx.status(`Searching ${generations.length} generations...`);
if (generations.length === 0) {
return JSON.stringify({
type: "draw-things-index-results",
query: args.query,
totalFound: 0,
error: "No generations indexed. Check if JSONL logs directory is configured correctly.",
searchTimeMs: 0,
images: [],
}, null, 2);
}
// Get config values for search tuning
const config = ctl.getGlobalPluginConfig(configSchematics);
const minMatchScore = config.get("minMatchScore");
const fuzzyTermThreshold = config.get("fuzzyTermThreshold");
const minTermCoverage = config.get("minTermCoverage");
// Semantic search config (enabled if weight > 0 and model configured)
const embeddingModel = config.get("embeddingModel");
const lmStudioUrl = config.get("lmStudioBaseUrl");
const semanticWeight = config.get("semanticWeight");
const minSemanticScore = config.get("minSemanticScore");
const isFilenameQuery = looksLikeImageFilenameQuery(query);
const isProjectFilenameQuery = looksLikeProjectFilenameQuery(query);
const userWantsSemanticSearch = !isFilenameQuery && !isProjectFilenameQuery && semanticWeight > 0 && !!embeddingModel;
console.info(`[Search Config] minMatchScore=${minMatchScore}, fuzzyTermThreshold=${fuzzyTermThreshold}, minTermCoverage=${minTermCoverage}`);
console.info(`[Search Config] filenameQuery=${isFilenameQuery}, semantic=${userWantsSemanticSearch}, model=${embeddingModel}, weight=${semanticWeight}`);
// Limit aus Plugin-Config (UI), nicht vom LLM überschreibbar
const limit = config.get("retrievalLimit");
// Project filename queries always return everything — limit is irrelevant.
const effectiveLimit = isProjectFilenameQuery || limit >= 25 ? 9999 : limit;
console.info(`[Search Config] retrievalLimit=${limit}, effectiveLimit=${effectiveLimit}`);
// ═══════════════════════════════════════════════════════════════
// EMBEDDING CAPABILITY CHECK (adapted from vision capability primer)
// ═══════════════════════════════════════════════════════════════
let semanticEnabled = false;
let actualEmbeddingModel = embeddingModel;
if (userWantsSemanticSearch) {
// Check if we need to refresh capability status
const now = Date.now();
if (!cachedCapabilityResult || (now - lastCapabilityCheckMs) > CAPABILITY_CHECK_INTERVAL_MS) {
ctx.status("Checking embedding model availability...");
cachedCapabilityResult = await checkEmbeddingCapability({
modelId: embeddingModel,
baseUrl: lmStudioUrl,
autoLoad: false, // Don't auto-load during search, just guide user
});
lastCapabilityCheckMs = now;
}
const capResult = cachedCapabilityResult;
if (capResult.ready && capResult.isLoaded) {
// Great! Semantic search is available
semanticEnabled = true;
actualEmbeddingModel = capResult.modelId; // Use actually loaded model
// Update client if model changed
if (!embeddingClient || embeddingClient.getModelName() !== actualEmbeddingModel) {
embeddingClient = new EmbeddingClient({
baseUrl: lmStudioUrl,
model: actualEmbeddingModel,
});
}
} else {
// Semantic search not available - graceful degradation
semanticEnabled = false;
console.warn("[index_image] Semantic search disabled:", capResult.error || "No embedding model loaded");
}
}
const result = await searchGenerations(query, generations, {
maxExactMatches: effectiveLimit,
maxSemanticMatches: effectiveLimit,
minExactScore: minMatchScore,
minSemanticScore: minSemanticScore,
includeSemanticSearch: semanticEnabled,
embeddingClient: semanticEnabled ? embeddingClient ?? undefined : undefined,
semanticWeight: semanticWeight,
fuzzyOptions: {
fuzzyTermThreshold,
minTermCoverage,
},
snapshot: enableModelRewrite ? snapshot : undefined,
});
ctx.status(`Found ${result.totalFound} results`);
// Stand-alone visualization switch (see config.ts). MCP-era env-var plumbing
// is obsolete — in LM Studio, process.env is the only mechanism that works.
const envPreviewRaw = process.env["PREVIEW_IN_CHAT"];
const previewInChat =
envPreviewRaw === undefined
? true
: envPreviewRaw === "1" || envPreviewRaw.toLowerCase() === "true";
const chatWdForPreview = ctl.getWorkingDirectory();
// Return structured content for draw-things-chat
return await buildToolResponse(result, {
enableModelRewrite,
snapshot,
previewInChat,
chatWd: typeof chatWdForPreview === "string" && chatWdForPreview.trim() ? chatWdForPreview : undefined,
});
} catch (e) {
return JSON.stringify({
type: "draw-things-index-results",
query: args.query,
error: String((e as any)?.message || e),
totalFound: 0,
searchTimeMs: 0,
images: [],
}, null, 2);
}
},
});
}
/**
* Build structured tool response.
* PREVIEW_IN_CHAT=false: returns the JSON string unchanged (current behavior).
* PREVIEW_IN_CHAT=true: additionally imports+previews+registers each match as a
* picture (pN) and returns one extra image content object per match with a $hint,
* the JSON payload itself stays byte-identical.
*
* @param result - Search results
*/
async function buildToolResponse(
result: DrawThingsSearchResult,
opts?: {
enableModelRewrite?: boolean;
snapshot?: DtcModelMappingSnapshotV1;
previewInChat?: boolean;
chatWd?: string;
}
): Promise<string | { content: any[] }> {
const enableModelRewrite = !!opts?.enableModelRewrite;
const snapshot = opts?.snapshot;
// Combine all matches into single array
const allMatches = [
...result.exactMatches.map(match => ({
matchType: match.matchType,
matchScore: match.matchScore,
prompt: match.prompt,
model: match.model,
...(enableModelRewrite
? { model_display: maybeFormatModelForConsumerFromSnapshot(match.model, true, snapshot) }
: {}),
...(enableModelRewrite
? (() => {
const hints = maybeGetModelRewriteHintsFromSnapshot(match.model, true, snapshot);
return hints ? { model_use_hints: hints } : {};
})()
: {}),
loras: match.loras || [],
width: match.width,
height: match.height,
numFrames: match.numFrames,
imagePaths: match.imagePaths,
httpPreviewUrls: match.httpPreviewUrls || [],
sourceInfo: match.sourceInfo,
timestamp: match.timestamp,
})),
...result.semanticMatches.map(match => ({
matchType: 'semantic' as const,
matchScore: match.matchScore,
prompt: match.prompt,
model: match.model,
...(enableModelRewrite
? { model_display: maybeFormatModelForConsumerFromSnapshot(match.model, true, snapshot) }
: {}),
...(enableModelRewrite
? (() => {
const hints = maybeGetModelRewriteHintsFromSnapshot(match.model, true, snapshot);
return hints ? { model_use_hints: hints } : {};
})()
: {}),
loras: match.loras || [],
width: match.width,
height: match.height,
numFrames: match.numFrames,
imagePaths: match.imagePaths,
httpPreviewUrls: match.httpPreviewUrls || [],
sourceInfo: match.sourceInfo,
timestamp: match.timestamp,
})),
];
// Sort by matchScore descending
allMatches.sort((a, b) => b.matchScore - a.matchScore);
const response: Record<string, any> = {
type: "draw-things-index-results",
query: result.query,
totalFound: allMatches.length,
searchTimeMs: result.searchTimeMs,
semanticSearchEnabled: result.semanticSearchEnabled,
images: allMatches,
};
const chatWd = opts?.chatWd;
if (!opts?.previewInChat || !chatWd) {
return JSON.stringify(response, null, 2);
}
const content: any[] = [];
try {
await fs.promises.mkdir(chatWd, { recursive: true });
const pluginId = getSelfPluginIdentifier() ?? "ceveyne/draw-things-index";
const previewOpts = getDefaultPreviewOptions();
const pictureRecords: any[] = [];
const sourceUrlByMatch: Array<string | undefined> = [];
for (const match of allMatches) {
const primaryPath = Array.isArray(match.imagePaths) ? match.imagePaths[0] : undefined;
if (!primaryPath) {
sourceUrlByMatch.push(undefined);
continue;
}
try {
const hasProjectUri = isProjectUri(primaryPath);
const hasLocalPath = !hasProjectUri && path.isAbsolute(primaryPath);
if (!hasProjectUri && !hasLocalPath) {
sourceUrlByMatch.push(undefined);
continue;
}
let sourceUrl = "";
let originalBaseName = "";
let previewBaseName = "";
let originalAbs = "";
let previewAbs = "";
let videoSourcePath: string | undefined;
let videoPreviewPath: string | undefined;
if (hasProjectUri) {
sourceUrl = primaryPath;
originalBaseName = projectPictureBaseName(primaryPath);
previewBaseName = previewFilenameFrom(originalBaseName);
originalAbs = path.join(chatWd, originalBaseName);
previewAbs = path.join(chatWd, previewBaseName);
} else {
const localOk = await fs.promises.access(primaryPath, fs.constants.F_OK).then(() => true).catch(() => false);
if (!localOk) {
sourceUrlByMatch.push(undefined);
continue;
}
sourceUrl = primaryPath;
const ext = path.extname(primaryPath) || ".png";
originalBaseName = basenameWithFallbackExtension(primaryPath, "picture.png", ext);
previewBaseName = previewFilenameFrom(originalBaseName);
originalAbs = path.join(chatWd, originalBaseName);
previewAbs = path.join(chatWd, previewBaseName);
// Video results (.mov/.mp4): sharp/jimp cannot decode video, so use the PNG
// last-frame sibling as the copyable "original" and the existing preview JPG
// sibling instead of attempting to generate one.
if (/\.(mov|mp4)$/i.test(ext)) {
const dir = path.dirname(primaryPath);
const base = path.basename(primaryPath, ext);
const pngSibling = path.join(dir, base + ".png");
const previewSibling = path.join(dir, "preview-" + base + ".jpg");
const pngOk = await fs.promises.access(pngSibling, fs.constants.F_OK).then(() => true).catch(() => false);
if (!pngOk) {
sourceUrlByMatch.push(undefined);
continue;
}
originalBaseName = basenameWithFallbackExtension(pngSibling, `${base}.png`, ".png");
previewBaseName = previewFilenameFrom(originalBaseName);
originalAbs = path.join(chatWd, originalBaseName);
previewAbs = path.join(chatWd, previewBaseName);
videoSourcePath = pngSibling;
videoPreviewPath = previewSibling;
}
}
const originalOk = await fs.promises.access(originalAbs, fs.constants.F_OK).then(() => true).catch(() => false);
const previewOk = await fs.promises.access(previewAbs, fs.constants.F_OK).then(() => true).catch(() => false);
if (!originalOk || !previewOk) {
if (hasProjectUri) {
const imgBuffer = await resolveProjectUri(primaryPath);
if (!imgBuffer) {
sourceUrlByMatch.push(undefined);
continue;
}
if (!originalOk) {
await fs.promises.writeFile(originalAbs, imgBuffer);
}
await generatePreview(originalAbs, chatWd, previewOpts, { customFilename: previewBaseName, force: true });
} else if (videoPreviewPath) {
if (!originalOk) {
await fs.promises.copyFile(videoSourcePath!, originalAbs);
}
if (!previewOk) {
const siblingOk = await fs.promises.access(videoPreviewPath, fs.constants.F_OK).then(() => true).catch(() => false);
if (siblingOk) {
await fs.promises.copyFile(videoPreviewPath, previewAbs);
} else {
await generatePreview(originalAbs, chatWd, previewOpts, { customFilename: previewBaseName, force: true });
}
}
} else {
if (!originalOk) {
await fs.promises.copyFile(primaryPath, originalAbs);
}
await generatePreview(originalAbs, chatWd, previewOpts, { customFilename: previewBaseName, force: true });
}
}
pictureRecords.push({
filename: originalBaseName,
preview: previewBaseName,
sourceTool: `${pluginId}/index_image`,
sourceUrl,
prompt: match.prompt,
model: match.model,
width: match.width,
height: match.height,
numFrames: match.numFrames,
timestamp: match.timestamp,
score: match.matchScore,
});
sourceUrlByMatch.push(sourceUrl);
} catch (err) {
console.warn("[index_image] picture materialization failed (non-fatal):", String(err));
sourceUrlByMatch.push(undefined);
}
}
if (pictureRecords.length > 0) {
const state = await readState(chatWd);
const appendResult = appendPictures(state, pictureRecords);
if (appendResult.changed) {
await writeStateAtomic(chatWd, state);
}
const pictures: any[] = Array.isArray((state as any)?.pictures) ? (state as any).pictures : [];
const recordBySourceUrl = new Map<string, any>();
for (const rec of pictures) {
if (typeof rec?.sourceUrl === "string" && rec.sourceUrl && !recordBySourceUrl.has(rec.sourceUrl)) {
recordBySourceUrl.set(rec.sourceUrl, rec);
}
}
for (let i = 0; i < allMatches.length; i++) {
const sourceUrl = sourceUrlByMatch[i];
if (!sourceUrl) continue;
const rec = recordBySourceUrl.get(sourceUrl);
if (rec && typeof rec.preview === "string" && typeof rec.p === "number") {
const match = allMatches[i];
const promptPreview = typeof match.prompt === "string" && match.prompt.trim()
? match.prompt.trim().slice(0, 80) + (match.prompt.trim().length > 80 ? "…" : "")
: undefined;
content.push({
type: "image",
fileName: rec.preview,
mimeType: "image/jpeg",
markdown: ``,
matchType: match.matchType,
matchScore: match.matchScore,
...(promptPreview ? { prompt: promptPreview } : {}),
$hint: "If this result fits the request, present it to the user by using the markdown above. If not, skip it and refine your query.",
} as any);
}
}
}
} catch (err) {
console.warn("[index_image] picture registration failed (non-fatal):", String(err));
}
content.push({ type: "text", text: JSON.stringify(response, null, 2) });
return { content };
}
/**
* index_image Tool
* Searches Draw Things generation history and returns structured results
*/
import { tool, type Tool, type ToolsProviderController, type ToolCallContext } from "@lmstudio/sdk";
// @ts-ignore — zod/lib re-export chain breaks with NodeNext moduleResolution; runtime is fine
import { z } from "zod";
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
import {
syncAttachmentsToState,
appendPictures,
readState,
writeStateAtomic,
generatePreview,
getDefaultPreviewOptions,
getSelfPluginIdentifier,
isProjectUri,
resolveProjectUri,
previewFilenameFrom,
} from "../core-bundle.mjs";
import { resolveMediaQueries } from "../media/mediaResolver.js";
import { searchGenerations } from "../search/searchEngine.js";
import { configSchematics } from "../config.js";
import { EmbeddingClient } from "../embeddings/index.js";
import { checkEmbeddingCapability, type EmbeddingPrimerResult } from "../helpers/embeddingCapabilityPrimer.js";
import { formatToolMetaBlock } from "../helpers/pluginMeta.js";
import {
parseDtcQuery,
maybeFormatModelForConsumerFromSnapshot,
maybeGetModelRewriteHintsFromSnapshot,
type DtcModelMappingSnapshotV1,
} from "../helpers/dtcModelMappingSnapshot.js";
import type { DrawThingsSearchResult } from "../types.js";
// Naming scheme ported 1:1 from draw-things-chat/src/services/toolResultHarvester.ts
// (materializePictureCandidates) — required for cross-plugin dedup of imported pictures.
function shortHexSha256(input: string, chars = 12): string {
const h = crypto.createHash("sha256").update(input).digest("hex");
return h.slice(0, Math.max(4, Math.min(64, chars)));
}
function sanitizeLocalFilename(name: string, fallback: string): string {
const cleaned = path.basename(String(name || "").trim())
.replace(/[\x00-\x1f\x7f]/g, "")
.replace(/:/g, "-")
.trim();
return cleaned || fallback;
}
function basenameWithFallbackExtension(name: string, fallbackName: string, fallbackExt: string): string {
const base = sanitizeLocalFilename(name, fallbackName);
return path.extname(base) ? base : `${base}${fallbackExt}`;
}
function projectPictureBaseName(uri: string): string {
const withoutScheme = uri.startsWith("project://") ? uri.slice("project://".length) : uri;
const hashIndex = withoutScheme.lastIndexOf("#");
const projectPath = hashIndex !== -1 ? withoutScheme.slice(0, hashIndex) : withoutScheme;
const previewIndex = hashIndex !== -1 ? withoutScheme.slice(hashIndex + 1).trim() : shortHexSha256(uri, 8);
const projectFile = sanitizeLocalFilename(projectPath, "project.sqlite3");
const safePreviewIndex = previewIndex.replace(/[^A-Za-z0-9_.-]+/g, "-") || shortHexSha256(uri, 8);
return `${projectFile}#${safePreviewIndex}.png`;
}
const IMAGE_FILENAME_EXTS = new Set(["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "bmp", "heic", "heif"]);
function looksLikeImageFilenameQuery(q: string): boolean {
const trimmed = q.trim().replace(/^['"]/,'').replace(/['"']$/,'');
const normalized = trimmed.replace(/\\/g, "/");
const base = normalized.split("/").pop() ?? normalized;
const dot = base.lastIndexOf(".");
if (dot <= 0 || dot === base.length - 1) return false;
const ext = base.slice(dot + 1).toLowerCase();
return IMAGE_FILENAME_EXTS.has(ext);
}
function looksLikeProjectFilenameQuery(q: string): boolean {
const trimmed = q.trim().replace(/^['"]/,'').replace(/['"']$/,'');
const normalized = trimmed.replace(/\\/g, "/");
const base = normalized.split("/").pop() ?? normalized;
return base.toLowerCase().endsWith('.sqlite3');
}
// Cached embedding client (reused across tool calls)
let embeddingClient: EmbeddingClient | null = null;
// Cached embedding capability check (refreshed periodically)
let cachedCapabilityResult: EmbeddingPrimerResult | null = null;
let lastCapabilityCheckMs = 0;
const CAPABILITY_CHECK_INTERVAL_MS = 30_000; // Re-check every 30s
// Tool parameters schema
const SearchParamsSchema = {
query: z.string().describe("Search query - filename, prompt text, model name, LoRA name, or keywords"),
};
export function createSearchGenerationsTool(ctl: ToolsProviderController): Tool {
return tool({
name: "index_image",
description: `Search Draw Things generation history by text metadata.
Use this tool for prompt, filename, model, LoRA, source, project, and timestamp search:
- Media references: aN (attachment), vN (variant), iN (image), pN (picture)
- File names: attachments, indexed image files, logged outputs, or project-backed images
- Draw Things project filename: returns all generations from that project in chronological order
- Prompt text and keywords, including cross-language semantic search when embeddings are available
Returns:
- Exact/fuzzy matches: direct filename, prompt, model, LoRA, project, or metadata hits
- Semantic matches: text-embedding matches when enabled, excluding project filename queries
- Image paths for each matching generation
Example queries:
- "a1" - searches by the first attachment's filename/original name
- "my-project.sqlite3" - returns all generations from that Draw Things project
- "flux cyberpunk portrait" - matches prompt/model metadata
- "Sonnenuntergang" - can match related text such as sunset via semantic search
${formatToolMetaBlock()}`,
parameters: SearchParamsSchema,
implementation: async (args: { query: string }, ctx: ToolCallContext) => {
try {
// Sync attachments from conversation.json → chat_media_state.json (non-fatal).
// Must run before query rewrite so tryBuildMediaIndex can resolve a1 → originalName.
try {
const workingDir = ctl.getWorkingDirectory();
if (typeof workingDir === "string" && workingDir.trim().length > 0) {
await syncAttachmentsToState(workingDir, false, Number.MAX_SAFE_INTEGER);
}
} catch (syncErr: any) {
console.warn(
"[index_image] attachment sync failed (non-fatal):",
syncErr?.message ?? syncErr,
);
}
ctx.status("Starting search...");
const { query: parsedQuery, enableModelRewrite, snapshot } = parseDtcQuery(args.query);
if (enableModelRewrite) {
ctx.status("Model rewrite enabled; snapshot received.");
}
// Rewrite index-notation tokens (a1, v2, i3, p4) to real filenames.
// syncAttachmentsToState above ensures chat_media_state.json exists.
let query = parsedQuery;
if (/\b[avip]\d+\b/i.test(query)) {
try {
const workingDir = ctl.getWorkingDirectory();
if (typeof workingDir === "string" && workingDir.trim()) {
const resolved = await resolveMediaQueries(workingDir, query);
if (resolved.rewrittenQuery !== query) {
console.info(`[index_image] Query rewrite: "${query}" → "${resolved.rewrittenQuery}"`);
query = resolved.rewrittenQuery;
}
}
} catch {
// non-fatal
}
}
// Get indexed generations with progress feedback
const { indexGenerations } = await import("../indexer.js");
const generations = await indexGenerations(
ctl,
false, // use cache if available
(msg: string) => ctx.status(msg)
);
ctx.status(`Searching ${generations.length} generations...`);
if (generations.length === 0) {
return JSON.stringify({
type: "draw-things-index-results",
query: args.query,
totalFound: 0,
error: "No generations indexed. Check if JSONL logs directory is configured correctly.",
searchTimeMs: 0,
images: [],
}, null, 2);
}
// Get config values for search tuning
const config = ctl.getGlobalPluginConfig(configSchematics);
const minMatchScore = config.get("minMatchScore");
const fuzzyTermThreshold = config.get("fuzzyTermThreshold");
const minTermCoverage = config.get("minTermCoverage");
// Semantic search config (enabled if weight > 0 and model configured)
const embeddingModel = config.get("embeddingModel");
const lmStudioUrl = config.get("lmStudioBaseUrl");
const semanticWeight = config.get("semanticWeight");
const minSemanticScore = config.get("minSemanticScore");
const isFilenameQuery = looksLikeImageFilenameQuery(query);
const isProjectFilenameQuery = looksLikeProjectFilenameQuery(query);
const userWantsSemanticSearch = !isFilenameQuery && !isProjectFilenameQuery && semanticWeight > 0 && !!embeddingModel;
console.info(`[Search Config] minMatchScore=${minMatchScore}, fuzzyTermThreshold=${fuzzyTermThreshold}, minTermCoverage=${minTermCoverage}`);
console.info(`[Search Config] filenameQuery=${isFilenameQuery}, semantic=${userWantsSemanticSearch}, model=${embeddingModel}, weight=${semanticWeight}`);
// Limit aus Plugin-Config (UI), nicht vom LLM überschreibbar
const limit = config.get("retrievalLimit");
// Project filename queries always return everything — limit is irrelevant.
const effectiveLimit = isProjectFilenameQuery || limit >= 25 ? 9999 : limit;
console.info(`[Search Config] retrievalLimit=${limit}, effectiveLimit=${effectiveLimit}`);
// ═══════════════════════════════════════════════════════════════
// EMBEDDING CAPABILITY CHECK (adapted from vision capability primer)
// ═══════════════════════════════════════════════════════════════
let semanticEnabled = false;
let actualEmbeddingModel = embeddingModel;
if (userWantsSemanticSearch) {
// Check if we need to refresh capability status
const now = Date.now();
if (!cachedCapabilityResult || (now - lastCapabilityCheckMs) > CAPABILITY_CHECK_INTERVAL_MS) {
ctx.status("Checking embedding model availability...");
cachedCapabilityResult = await checkEmbeddingCapability({
modelId: embeddingModel,
baseUrl: lmStudioUrl,
autoLoad: false, // Don't auto-load during search, just guide user
});
lastCapabilityCheckMs = now;
}
const capResult = cachedCapabilityResult;
if (capResult.ready && capResult.isLoaded) {
// Great! Semantic search is available
semanticEnabled = true;
actualEmbeddingModel = capResult.modelId; // Use actually loaded model
// Update client if model changed
if (!embeddingClient || embeddingClient.getModelName() !== actualEmbeddingModel) {
embeddingClient = new EmbeddingClient({
baseUrl: lmStudioUrl,
model: actualEmbeddingModel,
});
}
} else {
// Semantic search not available - graceful degradation
semanticEnabled = false;
console.warn("[index_image] Semantic search disabled:", capResult.error || "No embedding model loaded");
}
}
const result = await searchGenerations(query, generations, {
maxExactMatches: effectiveLimit,
maxSemanticMatches: effectiveLimit,
minExactScore: minMatchScore,
minSemanticScore: minSemanticScore,
includeSemanticSearch: semanticEnabled,
embeddingClient: semanticEnabled ? embeddingClient ?? undefined : undefined,
semanticWeight: semanticWeight,
fuzzyOptions: {
fuzzyTermThreshold,
minTermCoverage,
},
snapshot: enableModelRewrite ? snapshot : undefined,
});
ctx.status(`Found ${result.totalFound} results`);
// Stand-alone visualization switch (see config.ts). MCP-era env-var plumbing
// is obsolete — in LM Studio, process.env is the only mechanism that works.
const envPreviewRaw = process.env["PREVIEW_IN_CHAT"];
const previewInChat =
envPreviewRaw === undefined
? true
: envPreviewRaw === "1" || envPreviewRaw.toLowerCase() === "true";
const chatWdForPreview = ctl.getWorkingDirectory();
// Return structured content for draw-things-chat
return await buildToolResponse(result, {
enableModelRewrite,
snapshot,
previewInChat,
chatWd: typeof chatWdForPreview === "string" && chatWdForPreview.trim() ? chatWdForPreview : undefined,
});
} catch (e) {
return JSON.stringify({
type: "draw-things-index-results",
query: args.query,
error: String((e as any)?.message || e),
totalFound: 0,
searchTimeMs: 0,
images: [],
}, null, 2);
}
},
});
}
/**
* Build structured tool response.
* PREVIEW_IN_CHAT=false: returns the JSON string unchanged (current behavior).
* PREVIEW_IN_CHAT=true: additionally imports+previews+registers each match as a
* picture (pN) and returns one extra image content object per match with a $hint,
* the JSON payload itself stays byte-identical.
*
* @param result - Search results
*/
async function buildToolResponse(
result: DrawThingsSearchResult,
opts?: {
enableModelRewrite?: boolean;
snapshot?: DtcModelMappingSnapshotV1;
previewInChat?: boolean;
chatWd?: string;
}
): Promise<string | { content: any[] }> {
const enableModelRewrite = !!opts?.enableModelRewrite;
const snapshot = opts?.snapshot;
// Combine all matches into single array
const allMatches = [
...result.exactMatches.map(match => ({
matchType: match.matchType,
matchScore: match.matchScore,
prompt: match.prompt,
model: match.model,
...(enableModelRewrite
? { model_display: maybeFormatModelForConsumerFromSnapshot(match.model, true, snapshot) }
: {}),
...(enableModelRewrite
? (() => {
const hints = maybeGetModelRewriteHintsFromSnapshot(match.model, true, snapshot);
return hints ? { model_use_hints: hints } : {};
})()
: {}),
loras: match.loras || [],
width: match.width,
height: match.height,
numFrames: match.numFrames,
imagePaths: match.imagePaths,
httpPreviewUrls: match.httpPreviewUrls || [],
sourceInfo: match.sourceInfo,
timestamp: match.timestamp,
})),
...result.semanticMatches.map(match => ({
matchType: 'semantic' as const,
matchScore: match.matchScore,
prompt: match.prompt,
model: match.model,
...(enableModelRewrite
? { model_display: maybeFormatModelForConsumerFromSnapshot(match.model, true, snapshot) }
: {}),
...(enableModelRewrite
? (() => {
const hints = maybeGetModelRewriteHintsFromSnapshot(match.model, true, snapshot);
return hints ? { model_use_hints: hints } : {};
})()
: {}),
loras: match.loras || [],
width: match.width,
height: match.height,
numFrames: match.numFrames,
imagePaths: match.imagePaths,
httpPreviewUrls: match.httpPreviewUrls || [],
sourceInfo: match.sourceInfo,
timestamp: match.timestamp,
})),
];
// Sort by matchScore descending
allMatches.sort((a, b) => b.matchScore - a.matchScore);
const response: Record<string, any> = {
type: "draw-things-index-results",
query: result.query,
totalFound: allMatches.length,
searchTimeMs: result.searchTimeMs,
semanticSearchEnabled: result.semanticSearchEnabled,
images: allMatches,
};
const chatWd = opts?.chatWd;
if (!opts?.previewInChat || !chatWd) {
return JSON.stringify(response, null, 2);
}
const content: any[] = [];
try {
await fs.promises.mkdir(chatWd, { recursive: true });
const pluginId = getSelfPluginIdentifier() ?? "ceveyne/draw-things-index";
const previewOpts = getDefaultPreviewOptions();
const pictureRecords: any[] = [];
const sourceUrlByMatch: Array<string | undefined> = [];
for (const match of allMatches) {
const primaryPath = Array.isArray(match.imagePaths) ? match.imagePaths[0] : undefined;
if (!primaryPath) {
sourceUrlByMatch.push(undefined);
continue;
}
try {
const hasProjectUri = isProjectUri(primaryPath);
const hasLocalPath = !hasProjectUri && path.isAbsolute(primaryPath);
if (!hasProjectUri && !hasLocalPath) {
sourceUrlByMatch.push(undefined);
continue;
}
let sourceUrl = "";
let originalBaseName = "";
let previewBaseName = "";
let originalAbs = "";
let previewAbs = "";
let videoSourcePath: string | undefined;
let videoPreviewPath: string | undefined;
if (hasProjectUri) {
sourceUrl = primaryPath;
originalBaseName = projectPictureBaseName(primaryPath);
previewBaseName = previewFilenameFrom(originalBaseName);
originalAbs = path.join(chatWd, originalBaseName);
previewAbs = path.join(chatWd, previewBaseName);
} else {
const localOk = await fs.promises.access(primaryPath, fs.constants.F_OK).then(() => true).catch(() => false);
if (!localOk) {
sourceUrlByMatch.push(undefined);
continue;
}
sourceUrl = primaryPath;
const ext = path.extname(primaryPath) || ".png";
originalBaseName = basenameWithFallbackExtension(primaryPath, "picture.png", ext);
previewBaseName = previewFilenameFrom(originalBaseName);
originalAbs = path.join(chatWd, originalBaseName);
previewAbs = path.join(chatWd, previewBaseName);
// Video results (.mov/.mp4): sharp/jimp cannot decode video, so use the PNG
// last-frame sibling as the copyable "original" and the existing preview JPG
// sibling instead of attempting to generate one.
if (/\.(mov|mp4)$/i.test(ext)) {
const dir = path.dirname(primaryPath);
const base = path.basename(primaryPath, ext);
const pngSibling = path.join(dir, base + ".png");
const previewSibling = path.join(dir, "preview-" + base + ".jpg");
const pngOk = await fs.promises.access(pngSibling, fs.constants.F_OK).then(() => true).catch(() => false);
if (!pngOk) {
sourceUrlByMatch.push(undefined);
continue;
}
originalBaseName = basenameWithFallbackExtension(pngSibling, `${base}.png`, ".png");
previewBaseName = previewFilenameFrom(originalBaseName);
originalAbs = path.join(chatWd, originalBaseName);
previewAbs = path.join(chatWd, previewBaseName);
videoSourcePath = pngSibling;
videoPreviewPath = previewSibling;
}
}
const originalOk = await fs.promises.access(originalAbs, fs.constants.F_OK).then(() => true).catch(() => false);
const previewOk = await fs.promises.access(previewAbs, fs.constants.F_OK).then(() => true).catch(() => false);
if (!originalOk || !previewOk) {
if (hasProjectUri) {
const imgBuffer = await resolveProjectUri(primaryPath);
if (!imgBuffer) {
sourceUrlByMatch.push(undefined);
continue;
}
if (!originalOk) {
await fs.promises.writeFile(originalAbs, imgBuffer);
}
await generatePreview(originalAbs, chatWd, previewOpts, { customFilename: previewBaseName, force: true });
} else if (videoPreviewPath) {
if (!originalOk) {
await fs.promises.copyFile(videoSourcePath!, originalAbs);
}
if (!previewOk) {
const siblingOk = await fs.promises.access(videoPreviewPath, fs.constants.F_OK).then(() => true).catch(() => false);
if (siblingOk) {
await fs.promises.copyFile(videoPreviewPath, previewAbs);
} else {
await generatePreview(originalAbs, chatWd, previewOpts, { customFilename: previewBaseName, force: true });
}
}
} else {
if (!originalOk) {
await fs.promises.copyFile(primaryPath, originalAbs);
}
await generatePreview(originalAbs, chatWd, previewOpts, { customFilename: previewBaseName, force: true });
}
}
pictureRecords.push({
filename: originalBaseName,
preview: previewBaseName,
sourceTool: `${pluginId}/index_image`,
sourceUrl,
prompt: match.prompt,
model: match.model,
width: match.width,
height: match.height,
numFrames: match.numFrames,
timestamp: match.timestamp,
score: match.matchScore,
});
sourceUrlByMatch.push(sourceUrl);
} catch (err) {
console.warn("[index_image] picture materialization failed (non-fatal):", String(err));
sourceUrlByMatch.push(undefined);
}
}
if (pictureRecords.length > 0) {
const state = await readState(chatWd);
const appendResult = appendPictures(state, pictureRecords);
if (appendResult.changed) {
await writeStateAtomic(chatWd, state);
}
const pictures: any[] = Array.isArray((state as any)?.pictures) ? (state as any).pictures : [];
const recordBySourceUrl = new Map<string, any>();
for (const rec of pictures) {
if (typeof rec?.sourceUrl === "string" && rec.sourceUrl && !recordBySourceUrl.has(rec.sourceUrl)) {
recordBySourceUrl.set(rec.sourceUrl, rec);
}
}
for (let i = 0; i < allMatches.length; i++) {
const sourceUrl = sourceUrlByMatch[i];
if (!sourceUrl) continue;
const rec = recordBySourceUrl.get(sourceUrl);
if (rec && typeof rec.preview === "string" && typeof rec.p === "number") {
const match = allMatches[i];
const promptPreview = typeof match.prompt === "string" && match.prompt.trim()
? match.prompt.trim().slice(0, 80) + (match.prompt.trim().length > 80 ? "…" : "")
: undefined;
content.push({
type: "image",
fileName: rec.preview,
mimeType: "image/jpeg",
markdown: ``,
matchType: match.matchType,
matchScore: match.matchScore,
...(promptPreview ? { prompt: promptPreview } : {}),
$hint: "If this result fits the request, present it to the user by using the markdown above. If not, skip it and refine your query.",
} as any);
}
}
}
} catch (err) {
console.warn("[index_image] picture registration failed (non-fatal):", String(err));
}
content.push({ type: "text", text: JSON.stringify(response, null, 2) });
return { content };
}