Project Files
src / tools / show_image.ts
import { tool, type Tool, type ToolsProviderController, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import path from "path";
import { setActiveChatContext, readState, formatToolMetaBlock } from "../core-bundle.mjs";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parsePrefixedNotation(
s: string,
): { pool: "attachment" | "image" | "variant" | "picture"; index: number } | null {
const t = String(s || "").trim().toLowerCase();
const m = t.match(/^([avip])(\d+)$/);
if (!m) return null;
const idx = Math.max(1, parseInt(m[2], 10));
const pool =
m[1] === "a" ? "attachment" :
m[1] === "v" ? "variant" :
m[1] === "i" ? "image" :
"picture";
return { pool, index: idx };
}
// ─── Tool ─────────────────────────────────────────────────────────────────────
export function createShowImageTool(ctl: ToolsProviderController): Tool {
return tool({
name: "show_image",
description: `Display one or more images in the chat. Accepts any notation: pN (picture), iN (generated image), vN (variant), aN (attachment).
Use this after find_doc / review_image / analyse_image to show the selected image(s) to the user. This is the agent-controlled display step — nothing is shown automatically.
${formatToolMetaBlock()}`,
parameters: {
targets: z
.union([
z.string().transform((s) =>
(s.match(/[avip]\d+/gi) ?? []).map((x) => x.toLowerCase()),
),
z.array(z.string()),
])
.describe(
'One or more image notations: p=picture (p1, p2, …), i=generated image (i1, i2, …), ' +
'v=variant (v1, v2, …), a=attachment (a1, a2, …). ' +
'Pass via the targets field, e.g. show_image({"targets":["p1", "p2"]}).',
),
},
implementation: async (args: any, ctx: ToolCallContext) => {
// ── 1. Resolve working directory ────────────────────────────────────────
try {
const workingDir = ctl.getWorkingDirectory();
if (typeof workingDir === "string" && workingDir.trim().length > 0) {
const chatId = path.basename(workingDir);
if (/^\d+$/.test(chatId)) {
setActiveChatContext({ chatId, workingDir, requestId: `tool-${Date.now()}` });
}
}
} catch {
// best-effort
}
const chatWd = ctl.getWorkingDirectory();
if (typeof chatWd !== "string" || !chatWd.trim()) {
return "show_image failed: could not resolve LM Studio chat working directory.";
}
// ── 2. Parse targets ────────────────────────────────────────────────────
let rawTargets: string[] = [];
if (Array.isArray(args?.targets)) {
rawTargets = args.targets.map((t: any) => String(t).trim().toLowerCase());
} else if (typeof args?.targets === "string") {
rawTargets = (args.targets.match(/[avip]\d+/gi) ?? []).map((x: string) => x.toLowerCase());
}
if (rawTargets.length === 0) {
return "show_image: no valid notations provided (expected e.g. [\"p1\"] or [\"i2\", \"v1\"]).";
}
// ── 3. Read state ───────────────────────────────────────────────────────
let st: any;
try {
st = await readState(chatWd);
} catch (err) {
return `show_image: could not read chat state.\n${err instanceof Error ? err.message : String(err)}`;
}
const attachments: any[] = Array.isArray(st?.attachments) ? st.attachments : [];
const pictures: any[] = Array.isArray(st?.pictures) ? st.pictures : [];
const imageRecords: any[] = Array.isArray(st?.images) ? st.images : [];
const variantRecords: any[] = Array.isArray(st?.variants) ? st.variants : [];
// ── 4. Resolve preview filename for each target ─────────────────────────
const previewInChat =
String(process.env["PREVIEW_IN_CHAT"] || "").toLowerCase() === "true";
const content: any[] = [];
const errors: string[] = [];
for (const notation of rawTargets) {
const pref = parsePrefixedNotation(notation);
if (!pref) {
errors.push(`Invalid notation: ${notation}`);
continue;
}
let previewFilename: string | null = null;
if (pref.pool === "attachment") {
const rec = attachments.find((a: any) => a?.a === pref.index);
previewFilename = rec && typeof rec.preview === "string" ? rec.preview : null;
} else if (pref.pool === "image") {
const rec = imageRecords.find((r: any) => r?.i === pref.index);
previewFilename = rec && typeof rec.preview === "string" ? rec.preview : null;
} else if (pref.pool === "variant") {
const rec = variantRecords.find((v: any) => v?.v === pref.index);
previewFilename = rec && typeof rec.preview === "string" ? rec.preview : null;
} else {
const rec = pictures.find((p: any) => p?.p === pref.index);
previewFilename = rec && typeof rec.preview === "string" ? rec.preview : null;
}
if (!previewFilename) {
errors.push(`No preview found for ${notation}.`);
continue;
}
if (previewInChat) {
content.push({
type: "image",
fileName: previewFilename,
mimeType: "image/jpeg",
markdown: ``,
$hint: "This is an image file. Present the image to the user by using the markdown above.",
} as any);
} else {
content.push({
type: "text",
text: `${notation} successfully presented to the user.`,
});
}
}
if (errors.length > 0) {
content.push({ type: "text", text: errors.join("\n") });
}
if (content.length === 0) {
return errors.join("\n") || "show_image: no images could be resolved.";
}
try { ctx.status(`Showing ${rawTargets.length - errors.length} image(s)`); } catch {}
return { content };
},
});
}