Built-in design generation now runs as an agentic MCP tool-loop (reusing the agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the Settings experimental toggle; the orchestrator stays the default. - design-agent system prompt + in-process design toolset (parity-locked with the MCP surface) + flag-gated Intent::Design routing - spawn_agents execution as sequential sub-loops + live creation-mode badges (per-agent glow + 'N/M designing...' header) - new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state, export_nodes, spawn_agents; style-guide local audit - #27 AI panel restyle: rounded tool cards + green check-rings, gray user bubbles, model-pill bottom toolbar, header, empty-state pills, the PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown - multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI (switch / close / + / Cmd+T) with each run bound to its tab Large checkpoint commit spanning the working tree (Rust shell crates).
121 lines
3 KiB
TypeScript
121 lines
3 KiB
TypeScript
import { readFile, stat } from 'node:fs/promises';
|
|
import { extname, resolve } from 'node:path';
|
|
|
|
export const IMAGE_MIME_TYPES: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.bmp': 'image/bmp',
|
|
'.svg': 'image/svg+xml',
|
|
'.avif': 'image/avif',
|
|
};
|
|
|
|
const EXTENSIONLESS_IMAGE_CANDIDATES = Object.keys(IMAGE_MIME_TYPES);
|
|
|
|
export async function resolveServableLocalImagePath(path: string): Promise<{
|
|
resolvedPath: string;
|
|
mimeType: string;
|
|
} | null> {
|
|
const resolvedPath = resolve(path);
|
|
const extension = extname(resolvedPath).toLowerCase();
|
|
|
|
if (extension) {
|
|
const mimeType = IMAGE_MIME_TYPES[extension];
|
|
if (!mimeType) return null;
|
|
|
|
if (!(await isFile(resolvedPath))) return null;
|
|
return { resolvedPath, mimeType };
|
|
}
|
|
|
|
if (await isFile(resolvedPath)) {
|
|
const mimeType = await inferMimeTypeFromFile(resolvedPath);
|
|
if (!mimeType) return null;
|
|
return { resolvedPath, mimeType };
|
|
}
|
|
|
|
for (const candidateExt of EXTENSIONLESS_IMAGE_CANDIDATES) {
|
|
const candidatePath = `${resolvedPath}${candidateExt}`;
|
|
if (await isFile(candidatePath)) {
|
|
return {
|
|
resolvedPath: candidatePath,
|
|
mimeType: IMAGE_MIME_TYPES[candidateExt],
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function isFile(path: string): Promise<boolean> {
|
|
try {
|
|
const fileStat = await stat(path);
|
|
return fileStat.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function inferMimeTypeFromFile(path: string): Promise<string | null> {
|
|
const content = await readFile(path);
|
|
return inferMimeTypeFromBuffer(content);
|
|
}
|
|
|
|
function inferMimeTypeFromBuffer(content: Buffer): string | null {
|
|
if (
|
|
content.length >= 8 &&
|
|
content[0] === 0x89 &&
|
|
content[1] === 0x50 &&
|
|
content[2] === 0x4e &&
|
|
content[3] === 0x47 &&
|
|
content[4] === 0x0d &&
|
|
content[5] === 0x0a &&
|
|
content[6] === 0x1a &&
|
|
content[7] === 0x0a
|
|
) {
|
|
return 'image/png';
|
|
}
|
|
|
|
if (content.length >= 3 && content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff) {
|
|
return 'image/jpeg';
|
|
}
|
|
|
|
if (content.length >= 6) {
|
|
const header = content.subarray(0, 6).toString('ascii');
|
|
if (header === 'GIF87a' || header === 'GIF89a') {
|
|
return 'image/gif';
|
|
}
|
|
}
|
|
|
|
if (
|
|
content.length >= 12 &&
|
|
content.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
content.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
) {
|
|
return 'image/webp';
|
|
}
|
|
|
|
if (content.length >= 2 && content[0] === 0x42 && content[1] === 0x4d) {
|
|
return 'image/bmp';
|
|
}
|
|
|
|
if (
|
|
content.length >= 12 &&
|
|
content.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
|
/^avif|avis$/i.test(content.subarray(8, 12).toString('ascii'))
|
|
) {
|
|
return 'image/avif';
|
|
}
|
|
|
|
const textProbe = content.subarray(0, 512).toString('utf8').trimStart();
|
|
if (
|
|
textProbe.startsWith('<svg') ||
|
|
(textProbe.startsWith('<?xml') && textProbe.includes('<svg'))
|
|
) {
|
|
return 'image/svg+xml';
|
|
}
|
|
|
|
return null;
|
|
}
|