openpencil/apps/web/server/utils/local-asset.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
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).
2026-07-02 21:21:06 +08:00

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;
}