Files
maestro/src/engine/tools/image.ts
T
oss-sync 77ee3bc426
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

484 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, resolveOutputPathWithin } from './core.js';
import { logger } from '../../logger.js';
import { recordLlmUsage } from '../../llm/usage-recorder.js';
// --- Supported image extensions ---
const SUPPORTED_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
const DEFAULT_IMAGE_PROMPT = '画像の内容を日本語で説明してください。';
// Normalize extension for MIME type (jpg -> jpeg)
function extToMime(ext: string): string {
if (ext === 'jpg') return 'jpeg';
return ext;
}
// --- SVG annotation helpers ---
interface Annotation {
type: 'rectangle' | 'arrow' | 'text';
x?: number;
y?: number;
width?: number;
height?: number;
from_x?: number;
from_y?: number;
to_x?: number;
to_y?: number;
text?: string;
color?: string;
label?: string;
font_size?: number;
}
function computeScaling(imageWidth: number, imageHeight: number): { strokeWidth: number; fontSize: number } {
const shortSide = Math.min(imageWidth, imageHeight);
return {
strokeWidth: Math.max(2, Math.min(5, Math.round(shortSide / 400))),
fontSize: Math.max(12, Math.min(32, Math.round(shortSide / 40))),
};
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Convert a CSS color value into a string safe for use as an SVG `id`.
// SVG ids referenced via `url(#...)` may not contain spaces, parens, or commas,
// so functional notations like `rgb(255, 0, 0)` cannot be used as-is.
function colorToMarkerId(color: string): string {
return `ah-${color.replace(/[^a-zA-Z0-9]/g, '_')}`;
}
function buildAnnotationSvg(
imageWidth: number,
imageHeight: number,
annotations: Annotation[],
): string {
const { strokeWidth, fontSize: defaultFontSize } = computeScaling(imageWidth, imageHeight);
const markerSize = strokeWidth * 3;
const parts: string[] = [];
parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${imageWidth}" height="${imageHeight}">`);
parts.push('<defs>');
// One arrowhead marker per unique color
const arrowColors = new Set<string>();
for (const a of annotations) {
if (a.type === 'arrow') arrowColors.add(a.color ?? '#FF0000');
}
for (const color of arrowColors) {
const id = colorToMarkerId(color);
parts.push(
`<marker id="${id}" markerWidth="${markerSize}" markerHeight="${markerSize * 0.7}" ` +
`refX="${markerSize}" refY="${markerSize * 0.35}" orient="auto">` +
`<polygon points="0 0, ${markerSize} ${markerSize * 0.35}, 0 ${markerSize * 0.7}" fill="${color}"/>` +
'</marker>',
);
}
parts.push('</defs>');
for (const a of annotations) {
const color = a.color ?? '#FF0000';
const fontSize = a.font_size ?? defaultFontSize;
const labelPadding = Math.round(fontSize * 0.3);
switch (a.type) {
case 'rectangle': {
const x = a.x ?? 0;
const y = a.y ?? 0;
const w = a.width ?? 50;
const h = a.height ?? 50;
parts.push(
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
`fill="none" stroke="${color}" stroke-width="${strokeWidth}"/>`,
);
if (a.label) {
const labelWidth = a.label.length * fontSize * 0.6 + labelPadding * 2;
const labelHeight = fontSize + labelPadding * 2;
const labelY = y - labelHeight;
const textY = y - labelPadding;
parts.push(
`<rect x="${x}" y="${labelY}" width="${labelWidth}" height="${labelHeight}" ` +
`fill="${color}" rx="2"/>`,
);
parts.push(
`<text x="${x + labelPadding}" y="${textY}" ` +
`fill="white" font-size="${fontSize}" font-family="sans-serif">${escapeXml(a.label)}</text>`,
);
}
break;
}
case 'arrow': {
const fx = a.from_x ?? 0;
const fy = a.from_y ?? 0;
const tx = a.to_x ?? 0;
const ty = a.to_y ?? 0;
const markerId = colorToMarkerId(color);
parts.push(
`<line x1="${fx}" y1="${fy}" x2="${tx}" y2="${ty}" ` +
`stroke="${color}" stroke-width="${strokeWidth}" marker-end="url(#${markerId})"/>`,
);
if (a.label) {
const bgWidth = a.label.length * fontSize * 0.6 + labelPadding * 2;
const bgHeight = fontSize + labelPadding * 2;
parts.push(
`<rect x="${fx}" y="${fy - bgHeight}" width="${bgWidth}" height="${bgHeight}" ` +
`fill="rgba(0,0,0,0.6)" rx="3"/>`,
);
parts.push(
`<text x="${fx + labelPadding}" y="${fy - labelPadding}" ` +
`fill="${color}" font-size="${fontSize}" font-family="sans-serif">${escapeXml(a.label)}</text>`,
);
}
break;
}
case 'text': {
const x = a.x ?? 0;
const y = a.y ?? 0;
const text = a.text ?? '';
if (text) {
const bgWidth = text.length * fontSize * 0.6 + labelPadding * 2;
const bgHeight = fontSize + labelPadding * 2;
parts.push(
`<rect x="${x - labelPadding}" y="${y - fontSize}" width="${bgWidth}" height="${bgHeight}" ` +
`fill="rgba(0,0,0,0.6)" rx="3"/>`,
);
parts.push(
`<text x="${x}" y="${y}" ` +
`fill="${color}" font-size="${fontSize}" font-family="sans-serif">${escapeXml(text)}</text>`,
);
}
break;
}
}
}
parts.push('</svg>');
return parts.join('\n');
}
// --- Tool definition ---
const READIMAGE_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadImage',
description: '画像ファイルを LLM に直接渡して内容を認識させる(VLM 対応 worker のみ)。詳細は ReadToolDoc({ name: "ReadImage" })。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'workspace 内の画像ファイルパス' },
prompt: { type: 'string', description: '画像について質問したいこと(省略可)' },
},
required: ['file_path'],
},
},
};
const ANNOTATEIMAGE_DEF: ToolDef = {
type: 'function',
function: {
name: 'AnnotateImage',
description: '画像上に矩形枠・矢印・テキストラベルを描画して output/ に保存する(元画像は変更されない)。詳細は ReadToolDoc({ name: "AnnotateImage" })。',
parameters: {
type: 'object',
properties: {
input_path: { type: 'string', description: 'workspace 内の元画像ファイルパス' },
output_path: { type: 'string', description: '出力先パス(output/ 配下)' },
annotations: {
type: 'array',
description: '描画する注釈の配列',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['rectangle', 'arrow', 'text'], description: '注釈の種類' },
x: { type: 'number', description: 'rectangle/text: 左上 X 座標' },
y: { type: 'number', description: 'rectangle/text: 左上 Y 座標' },
width: { type: 'number', description: 'rectangle: 幅' },
height: { type: 'number', description: 'rectangle: 高さ' },
from_x: { type: 'number', description: 'arrow: 始点 X' },
from_y: { type: 'number', description: 'arrow: 始点 Y' },
to_x: { type: 'number', description: 'arrow: 終点 X' },
to_y: { type: 'number', description: 'arrow: 終点 Y' },
text: { type: 'string', description: 'text: 表示するテキスト' },
color: { type: 'string', description: '色(デフォルト: #FF0000)' },
label: { type: 'string', description: 'rectangle/arrow: ラベルテキスト' },
font_size: { type: 'number', description: 'フォントサイズ(省略時は画像サイズから自動算出)' },
},
required: ['type'],
},
},
},
required: ['input_path', 'output_path', 'annotations'],
},
},
};
export const TOOL_DEFS: Record<string, ToolDef> = {
ReadImage: READIMAGE_DEF,
AnnotateImage: ANNOTATEIMAGE_DEF,
};
// --- Tool execution ---
export function resolveImagePath(
filePath: string,
ctx: ToolContext,
): ToolResult | { resolved: string; ext: string; dataUrl: string } {
let resolved: string;
try {
resolved = resolveAndGuard(ctx.workspacePath, filePath);
} catch (e) {
return { output: (e as Error).message, isError: true };
}
const ext = path.extname(resolved).replace('.', '').toLowerCase();
if (!SUPPORTED_EXTENSIONS.has(ext)) {
return {
output: `Unsupported image extension: "${ext}". Supported: ${[...SUPPORTED_EXTENSIONS].join(', ')}`,
isError: true,
};
}
let base64data: string;
try {
const buf = fs.readFileSync(resolved);
base64data = buf.toString('base64');
} catch (e) {
return { output: `Failed to read image: ${(e as Error).message}`, isError: true };
}
const mimeExt = extToMime(ext);
return {
resolved,
ext,
dataUrl: `data:image/${mimeExt};base64,${base64data}`,
};
}
export async function callVisionModel(
dataUrl: string,
prompt: string,
ctx: ToolContext,
): Promise<ToolResult> {
const finalPrompt = prompt.trim() || DEFAULT_IMAGE_PROMPT;
const toolsConfig = ctx.toolsConfig ?? {};
const visionModel = toolsConfig.visionModel ?? 'qwen2-vl:8b-instruct';
// Neutral placeholder — set tools.vision_base_url in config.yaml for a real endpoint.
// (Workers usually override this with their own endpoint; see worker.ts)
const visionBaseUrl = toolsConfig.visionBaseUrl ?? 'http://10.0.0.10:11434/v1';
const visionTimeout = (toolsConfig.visionTimeout ?? 60) * 1000;
const visionMaxTokens = toolsConfig.visionMaxTokens ?? 1024;
const requestBody = {
model: visionModel,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: finalPrompt },
{ type: 'image_url', image_url: { url: dataUrl } },
],
},
],
max_tokens: visionMaxTokens,
};
logger.debug(`[ReadImage] calling ${visionBaseUrl}/chat/completions model=${visionModel}`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), visionTimeout);
try {
const response = await fetch(`${visionBaseUrl}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { output: `Vision API error ${response.status}: ${text}`, isError: true };
}
const json = (await response.json()) as {
model?: string;
choices?: Array<{ message?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const content = json.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
return { output: 'Vision API returned no content', isError: true };
}
// This vision call is a raw fetch outside OpenAICompatClient, so it would
// otherwise miss the per-user usage ledger. Record it directly (issue
// #499). Vision uses its own endpoint directly (never the gateway).
let route = 'unknown';
try { route = new URL(visionBaseUrl).host || 'unknown'; } catch { /* keep 'unknown' */ }
recordLlmUsage({
userId: ctx.userId ?? 'local',
source: 'direct',
model: json.model || visionModel,
route,
tokensIn: json.usage?.prompt_tokens ?? 0,
tokensOut: json.usage?.completion_tokens ?? 0,
});
return { output: content, isError: false };
} catch (e) {
if ((e as Error).name === 'AbortError') {
return { output: `Vision API timed out after ${visionTimeout / 1000}s`, isError: true };
}
return { output: `Vision API request failed: ${(e as Error).message}`, isError: true };
} finally {
clearTimeout(timer);
}
}
async function executeReadImage(
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult> {
if (!ctx.vlmEnabled) {
return { output: 'ReadImage requires VLM-enabled worker (vlm: true)', isError: true };
}
const filePath = input['file_path'] as string;
const prompt = typeof input['prompt'] === 'string' ? input['prompt'] : '';
const resolvedImage = resolveImagePath(filePath, ctx);
if ('isError' in resolvedImage) return resolvedImage;
const description = prompt
? `画像を読み込みました: ${filePath}\n指示: ${prompt}`
: `画像を読み込みました: ${filePath}`;
return {
output: description,
isError: false,
images: [{ dataUrl: resolvedImage.dataUrl, label: filePath }],
};
}
function isValidCssColor(color: string): boolean {
if (typeof color !== 'string') return false;
// Hex: #RGB, #RRGGBB, #RRGGBBAA
if (/^#[0-9A-Fa-f]{3}$|^#[0-9A-Fa-f]{6}$|^#[0-9A-Fa-f]{8}$/.test(color)) return true;
// Named colors (lowercase letters only — covers "red", "blue", "transparent", etc.)
if (/^[a-z]+$/.test(color)) return true;
// rgb(), rgba(), hsl(), hsla() — basic shape only
if (/^(?:rgb|rgba|hsl|hsla)\(\s*[\d.,%\s/]+\s*\)$/.test(color)) return true;
return false;
}
const VALID_TYPES = new Set(['rectangle', 'arrow', 'text']);
async function executeAnnotateImage(
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult> {
if (!ctx.editAllowed) {
return { output: 'AnnotateImage is not allowed: edit flag is false', isError: true };
}
const inputPath = input['input_path'] as string;
const outputPath = input['output_path'] as string;
const annotations = input['annotations'] as Annotation[];
if (!Array.isArray(annotations) || annotations.length === 0) {
return { output: 'AnnotateImage requires at least one annotation', isError: true };
}
for (let i = 0; i < annotations.length; i++) {
const a = annotations[i];
if (!VALID_TYPES.has(a.type)) {
return {
output: `AnnotateImage failed: annotation[${i}].type "${a.type}" is invalid. Must be one of: rectangle, arrow, text.`,
isError: true,
};
}
if (a.color !== undefined && !isValidCssColor(a.color)) {
return {
output: `AnnotateImage failed: annotation[${i}] has invalid color "${a.color}". Use formats like "#FF0000", "red", or "rgb(255,0,0)".`,
isError: true,
};
}
}
// Resolve and guard input path
let resolvedInput: string;
try {
resolvedInput = resolveAndGuard(ctx.workspacePath, inputPath);
} catch (e) {
return { output: (e as Error).message, isError: true };
}
// Resolve and guard output path (must be within output/)
let resolvedOutput: string;
try {
resolvedOutput = resolveOutputPathWithin(ctx.workspacePath, outputPath, ['output']);
} catch (e) {
return { output: (e as Error).message, isError: true };
}
try {
// Read image metadata
const metadata = await sharp(resolvedInput).metadata();
if (!metadata.width || !metadata.height) {
return {
output: `AnnotateImage failed: cannot determine image dimensions for ${inputPath}`,
isError: true,
};
}
const imageWidth = metadata.width;
const imageHeight = metadata.height;
// Build SVG overlay
const svg = buildAnnotationSvg(imageWidth, imageHeight, annotations);
const svgBuffer = Buffer.from(svg);
// Composite SVG onto the original image and save as PNG
fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
await sharp(resolvedInput)
.composite([{ input: svgBuffer, top: 0, left: 0 }])
.png()
.toFile(resolvedOutput);
const count = annotations.length;
return {
output: `Annotated image saved to ${outputPath} (${count} annotation${count !== 1 ? 's' : ''} applied)`,
isError: false,
};
} catch (e) {
return { output: `AnnotateImage failed: ${(e as Error).message}`, isError: true };
}
}
export async function executeTool(
name: string,
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
switch (name) {
case 'ReadImage':
return executeReadImage(input, ctx);
case 'AnnotateImage':
return executeAnnotateImage(input, ctx);
default:
return null;
}
}