This commit is contained in:
kim
2026-04-29 17:34:36 +09:00
parent ee10549c5a
commit 4324985508
5 changed files with 108 additions and 45 deletions
+48 -10
View File
@@ -131,6 +131,12 @@ async function generateWithPython(spec: PresentationSpec, workspacePath: string)
try {
// Write spec to temp file
fs.writeFileSync(tmpSpecPath, JSON.stringify(spec), 'utf-8');
console.log(`[pptx] generateWithPython: workspace=${workspacePath} slides=${spec.slides.length} title="${spec.title}"`);
for (const [i, s] of spec.slides.entries()) {
if (s.image_path || s.image_url) {
console.log(`[pptx] generateWithPython: slide ${i + 1} type=${s.type} image_path="${s.image_path}" image_url="${s.image_url?.slice(0, 60) || ''}"`);
}
}
const result = await new Promise<{ success: boolean; path?: string; folder?: string; filename?: string; slides?: number; warnings?: string[]; stdout?: string; error?: string; download_url?: string; preview_url?: string }>((resolve, reject) => {
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
@@ -142,7 +148,7 @@ async function generateWithPython(spec: PresentationSpec, workspacePath: string)
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
}, (err, stdout, stderr) => {
if (stderr) {
console.error(`[pptx] Python stderr: ${stderr.slice(0, 800)}`);
console.error(`[pptx] Python stderr: ${stderr.slice(0, 3000)}`);
}
if (err) {
console.error(`[pptx] Python process failed: ${err.message}`);
@@ -230,7 +236,8 @@ async function downloadImageByUrl(url: string, dest: string): Promise<boolean> {
const resp = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
// Prefer JPEG/PNG over WebP/AVIF — PowerPoint doesn't support WebP/AVIF
'Accept': 'image/jpeg,image/png,image/gif,image/*,*/*;q=0.8',
'Sec-Fetch-Dest': 'image',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site',
@@ -239,12 +246,28 @@ async function downloadImageByUrl(url: string, dest: string): Promise<boolean> {
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) return false;
if (!resp.ok) {
console.log(`[pptx] downloadImageByUrl: HTTP ${resp.status} for ${url.slice(0, 80)}`);
return false;
}
const buffer = Buffer.from(await resp.arrayBuffer());
if (buffer.length < 100) return false;
if (buffer.length < 100) {
console.log(`[pptx] downloadImageByUrl: too small (${buffer.length} bytes) for ${url.slice(0, 80)}`);
return false;
}
const ct = resp.headers.get('content-type') || '';
// Detect actual format from magic bytes
let actualFmt = 'unknown';
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) actualFmt = 'jpeg';
else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) actualFmt = 'png';
else if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) actualFmt = 'gif';
else if (buffer.length > 8 && buffer.slice(4, 12).toString('ascii') === 'ftypavif') actualFmt = 'avif';
else if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) actualFmt = 'webp';
console.log(`[pptx] downloadImageByUrl: ${buffer.length} bytes, ct="${ct}", magic=${actualFmt}, dest=${path.basename(dest)}`);
fs.writeFileSync(dest, buffer);
return true;
} catch {
} catch (e: any) {
console.log(`[pptx] downloadImageByUrl: failed: ${e.message} for ${url.slice(0, 80)}`);
return false;
}
}
@@ -338,6 +361,7 @@ const FREE_IMAGE_APIS = [
];
async function searchAndDownloadImage(query: string, projectDir: string, slideIndex: number, sendSSE?: SSESender): Promise<string | null> {
console.log(`[pptx] searchAndDownloadImage: slide ${slideIndex + 1}, query="${query}", dir=${projectDir}`);
sendSSE?.('info', { message: `🖼 Slide ${slideIndex + 1}: searching for "${query}"...` });
// Strategy 1: Browser search (most reliable — handles JS, anti-bot, etc.)
@@ -348,10 +372,11 @@ async function searchAndDownloadImage(query: string, projectDir: string, slideIn
const dest = path.join(projectDir, fname);
sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: downloading from browser result...` });
if (await downloadImageByUrl(browserUrl, dest)) {
console.log(`[pptx] Downloaded browser image for slide ${slideIndex + 1}: ${browserUrl.slice(0, 80)}`);
console.log(`[pptx] searchAndDownloadImage: slide ${slideIndex + 1} browser OK, fname=${fname}`);
sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image downloaded (browser)` });
return fname;
}
console.log(`[pptx] searchAndDownloadImage: slide ${slideIndex + 1} browser download failed`);
sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: browser download failed, trying API...` });
}
@@ -364,10 +389,11 @@ async function searchAndDownloadImage(query: string, projectDir: string, slideIn
const fname = `slide${slideIndex + 1}_image.jpg`;
const dest = path.join(projectDir, fname);
if (await downloadImageByUrl(url, dest)) {
console.log(`[pptx] Downloaded picsum image for slide ${slideIndex + 1}`);
console.log(`[pptx] searchAndDownloadImage: slide ${slideIndex + 1} picsum OK`);
sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image downloaded (picsum)` });
return fname;
}
console.log(`[pptx] searchAndDownloadImage: slide ${slideIndex + 1} picsum failed`);
sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: picsum download failed` });
continue;
}
@@ -397,10 +423,11 @@ async function searchAndDownloadImage(query: string, projectDir: string, slideIn
const dest = path.join(projectDir, fname);
sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: downloading from ${api.name}...` });
if (await downloadImageByUrl(imageUrl, dest)) {
console.log(`[pptx] Downloaded ${api.name} image for slide ${slideIndex + 1}`);
console.log(`[pptx] searchAndDownloadImage: slide ${slideIndex + 1} ${api.name} OK, fname=${fname}`);
sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image downloaded (${api.name})` });
return fname;
}
console.log(`[pptx] searchAndDownloadImage: slide ${slideIndex + 1} ${api.name} download failed`);
sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: ${api.name} download failed` });
} catch (e: any) {
console.log(`[pptx] ${api.name} search failed for "${query}": ${e.message}`);
@@ -438,7 +465,7 @@ interface PresentationSpec {
export const pptxTool: import('./registry.js').Tool = {
name: 'create_presentation',
description: 'Generate a PowerPoint (.pptx) file with slides. Use image_search on slides to auto-find images by keyword (e.g. "golden retriever"). Use image_url for direct URLs. Keep slides to 6 or fewer per call. NEVER write Python scripts to create PPTX. Missing images become light-gray placeholders.',
description: 'Generate a PowerPoint (.pptx) file with slides. Use image_search on slides to auto-find images by keyword (e.g. "golden retriever"). Use image_url for direct URLs. For very long presentations (10+ slides), consider splitting across multiple calls using edit_presentation. NEVER write Python scripts to create PPTX. Missing images become light-gray placeholders.',
schema: {
spec: 'JSON object: { filename, title, template, theme, font_sizes, slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, background, template }] }',
},
@@ -515,6 +542,7 @@ export const pptxTool: import('./registry.js').Tool = {
if (slidesNeedingImages.length > 0) {
const projectSlug = (spec.title || 'presentation').replace(/[^a-zA-Z0-9가-힣_\-]/g, '_').toLowerCase().slice(0, 60);
const projectDir = path.join(workspacePath, projectSlug);
console.log(`[pptx] image_search: resolving ${slidesNeedingImages.length} slides, projectDir=${projectDir}`);
if (!fs.existsSync(projectDir)) {
fs.mkdirSync(projectDir, { recursive: true });
}
@@ -523,20 +551,30 @@ export const pptxTool: import('./registry.js').Tool = {
const idx = spec.slides.indexOf(slide);
const sendSSE = (args as any)?._sendSSE as SSESender | undefined;
const localPath = await searchAndDownloadImage(slide.image_search, projectDir, idx, sendSSE);
console.log(`[pptx] image_search: slide ${idx + 1} keyword="${slide.image_search}" -> localPath="${localPath}"`);
if (localPath) {
const fullPath = path.join(projectDir, localPath);
console.log(`[pptx] image_search: file exists=${fs.existsSync(fullPath)} size=${fs.statSync(fullPath).size} at ${fullPath}`);
slide.image_path = localPath;
}
delete slide.image_search;
}
}
// Debug: log final slide specs for image slides
for (const [i, s] of spec.slides.entries()) {
if (s.image_path || s.image_url || s.image_search) {
console.log(`[pptx] slide ${i + 1}: type=${s.type} image_path="${s.image_path}" image_url="${s.image_url}"`);
}
}
return await generateWithPython(spec, workspacePath);
},
};
export const editPptxTool: import('./registry.js').Tool = {
name: 'edit_presentation',
description: 'Append slides to an existing .pptx file. Provide the path and new slides. Use image_search to auto-find images. Keep slides to 6 or fewer per call.',
description: 'Append slides to an existing .pptx file. Provide the path and new slides. Use image_search to auto-find images. For very long additions (10+ slides), consider splitting across multiple calls.',
schema: {
path: 'Path to existing .pptx file (relative to workspace or absolute)',
spec: 'JSON object: { slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, background, template }] }',