Release 2.8.1: pptx-wizard slide editor + fixes

- Fix pptx-wizard slide editor modal opening off-screen (header clipped)
  when a saved drag position is restored on a fresh page load: clear the
  CSS centering transform and clamp coordinates into the viewport.
- Add pptx-wizard.html and python-runner.html web UIs.
- Bump version to 2.8.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-30 20:14:01 +09:00
co-authored by Claude Opus 4.8
parent 22bfc3ac27
commit 957a13f98c
17 changed files with 4867 additions and 103 deletions
+68 -9
View File
@@ -179,7 +179,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, 3000)}`);
console.debug(`[pptx] Python stderr: ${stderr.slice(0, 3000)}`);
}
if (err) {
console.error(`[pptx] Python process failed: ${err.message}`);
@@ -265,6 +265,10 @@ interface SlideSpec {
right_body?: string;
// Timeline slide
events?: { year?: string; label?: string; text?: string; description?: string }[];
// Image effects (Pillow-based post-processing)
img_filter?: string;
img_blur_radius?: number;
img_corner_radius?: number;
// Edit mode: 1-based slide number to replace (used with edit_presentation)
replace_index?: number;
}
@@ -305,6 +309,11 @@ async function downloadImageByUrl(url: string, dest: string): Promise<boolean> {
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';
// Reject HTML responses (e.g. PMC figure page URLs like .../bin/Fig1_HTM)
if (actualFmt === 'unknown' && (ct.includes('text/html') || buffer.slice(0, 15).toString('ascii').toLowerCase().includes('<!doc'))) {
console.log(`[pptx] downloadImageByUrl: HTML response rejected for ${url.slice(0, 80)}`);
return false;
}
console.log(`[pptx] downloadImageByUrl: ${buffer.length} bytes, ct="${ct}", magic=${actualFmt}, dest=${path.basename(dest)}`);
fs.writeFileSync(dest, buffer);
return true;
@@ -578,10 +587,12 @@ interface FontSizes {
interface PresentationSpec {
filename?: string;
title?: string;
project?: string;
theme?: string;
template?: string;
default_skin?: string;
font_sizes?: FontSizes;
source_files?: string[];
slides: SlideSpec[];
}
@@ -591,7 +602,7 @@ export const pptxTool: import('./registry.js').Tool = {
name: 'create_presentation',
description: 'Generate a PowerPoint (.pptx) file with slides. Slide types: "title", "content", "section", "image", "table", "chart", "timeline", "blank". Content layouts: "split-right" (default), "split-left", "text", "fullscreen", "compare". Use image_search to auto-find images. Put ALL slides in ONE call — never split. NEVER write Python scripts to create PPTX.',
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 }] }',
spec: 'JSON object: { filename, title, template, theme, font_sizes, slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, img_filter, img_blur_radius, img_corner_radius, background, template }] }',
},
jsonSchema: {
type: 'object',
@@ -602,9 +613,11 @@ export const pptxTool: import('./registry.js').Tool = {
properties: {
filename: { type: 'string', description: 'Output filename (default: presentation.pptx)' },
title: { type: 'string', description: 'Presentation title — used to name the project folder' },
project: { type: 'string', description: 'Project folder name (alternative to title — use this to save into an existing folder)' },
template: { type: 'string', description: `Template: ${TEMPLATE_NAMES.join(', ')}` },
theme: { type: 'string', description: 'Override: "dark" or "light"' },
font_sizes: { type: 'object', description: 'Override default font sizes (pt). Keys: title(40), subtitle(20), slide_title(28), body(18), bullets(18), section(36), image_title(24)' },
source_files: { type: 'array', items: { type: 'string' }, description: 'Workspace-relative paths of source files (PDFs, extracted image dirs) to copy into the project folder. E.g. ["pubmed/PMC123.pdf", "uploads/PMC123_images"]' },
slides: {
type: 'array',
description: 'Array of slide specifications',
@@ -623,6 +636,9 @@ export const pptxTool: import('./registry.js').Tool = {
image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become light-gray placeholders.' },
image_url: { type: 'string', description: 'Auto-download this image URL into the project folder. Overrides image_path if both provided. Supports http/https URLs.' },
image_search: { type: 'string', description: 'Search keyword to find an image automatically (e.g. "golden retriever", "mountain sunset"). Overrides image_url if both provided. Images are downloaded before slides are created.' },
img_filter: { type: 'string', description: 'Image effect filter: "grayscale" (흑백), "blur" (블러), "contrast" (대비 강화), "bright" (밝게), "round" (둥근 모서리), or "none". Applied to the slide image via Pillow before embedding.' },
img_blur_radius: { type: 'number', description: 'Blur radius when img_filter="blur" (default: 5, range: 1-20)' },
img_corner_radius: { type: 'number', description: 'Corner radius % when img_filter="round" (default: 15, range: 5-50)' },
background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` },
template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` },
notes: { type: 'string', description: 'Speaker notes' },
@@ -680,7 +696,7 @@ export const pptxTool: import('./registry.js').Tool = {
}
// ── Phase 1: Create project directory ──
const projectSlug = (spec.title || 'presentation')
const projectSlug = (spec.title || spec.project || 'presentation')
.replace(/\.pptx$/i, '')
.replace(/[^a-zA-Z0-9가-힣_\-]/g, '_')
.replace(/_+/g, '_')
@@ -722,14 +738,21 @@ export const pptxTool: import('./registry.js').Tool = {
}
}
if (slide.image_url && !slide.image_path) {
const url = slide.image_url;
let url = slide.image_url;
// /api/files/<relative-path> is a server API alias for workspace/<relative-path>
if (url.startsWith('/api/files/')) {
url = path.join(workspacePath, decodeURIComponent(url.slice('/api/files/'.length)));
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
// Local workspace-relative path — copy to project folder
const srcPath = path.isAbsolute(url) ? url : path.join(workspacePath, url);
if (fs.existsSync(srcPath)) {
const ext = path.extname(srcPath) || '.png';
// Try original path, then spaces→underscores variant
const srcPathAlt = srcPath.replace(/ /g, '_');
const resolvedSrc = fs.existsSync(srcPath) ? srcPath : fs.existsSync(srcPathAlt) ? srcPathAlt : null;
if (resolvedSrc) {
const ext = path.extname(resolvedSrc) || '.png';
const fname = `slide${i + 1}_image${ext}`;
fs.copyFileSync(srcPath, path.join(projectDir, fname));
fs.copyFileSync(resolvedSrc, path.join(projectDir, fname));
slide.image_path = fname;
console.log(`[pptx] slide ${i + 1} image_url local copy -> ${fname}`);
} else {
@@ -826,6 +849,39 @@ export const pptxTool: import('./registry.js').Tool = {
}
}
// ── Phase 2.5: Copy source files (PDFs, image dirs) into project folder ──
const sourceFiles: string[] = Array.isArray(spec.source_files) ? spec.source_files : [];
for (const srcRel of sourceFiles) {
const srcAbs = path.isAbsolute(srcRel) ? srcRel : path.join(workspacePath, srcRel);
if (!fs.existsSync(srcAbs)) {
console.log(`[pptx] source_files: not found, skipping: ${srcRel}`);
continue;
}
const stat = fs.statSync(srcAbs);
if (stat.isDirectory()) {
// Copy entire directory (e.g., uploads/PMC123_images → projectDir/PMC123_images)
const destDir = path.join(projectDir, path.basename(srcAbs));
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
for (const f of fs.readdirSync(srcAbs)) {
try {
fs.copyFileSync(path.join(srcAbs, f), path.join(destDir, f));
} catch (e) {
console.log(`[pptx] source_files: could not copy ${f}: ${e}`);
}
}
console.log(`[pptx] source_files: copied dir ${path.basename(srcAbs)} -> ${destDir}`);
} else {
// Copy single file (e.g., pubmed/PMC123.pdf → projectDir/PMC123.pdf)
const dest = path.join(projectDir, path.basename(srcAbs));
try {
fs.copyFileSync(srcAbs, dest);
console.log(`[pptx] source_files: copied ${path.basename(srcAbs)} -> ${dest}`);
} catch (e) {
console.log(`[pptx] source_files: could not copy ${srcAbs}: ${e}`);
}
}
}
// ── Phase 3: Generate PPTX ──
return await generateWithPython(spec, workspacePath);
},
@@ -833,10 +889,10 @@ export const pptxTool: import('./registry.js').Tool = {
export const editPptxTool: import('./registry.js').Tool = {
name: 'edit_presentation',
description: 'Append slides to an existing .pptx file. Supports all slide types: title, content, section, image, table, chart, timeline. Use image_search to auto-find images. Do NOT use this to split a single presentation — use create_presentation for all slides at once.',
description: 'Append slides to an existing .pptx file. Supports all slide types: title, content, section, image, table, chart, timeline. Use image_search to auto-find images. Supports img_filter for image effects (grayscale, blur, contrast, bright, round). Do NOT use this to split a single presentation — use create_presentation for all slides at once.',
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 }] }',
spec: 'JSON object: { slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, img_filter, img_blur_radius, img_corner_radius, background, template }] }',
},
jsonSchema: {
type: 'object',
@@ -865,6 +921,9 @@ export const editPptxTool: import('./registry.js').Tool = {
image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become light-gray placeholders.' },
image_url: { type: 'string', description: 'Auto-download this image URL into the project folder. Overrides image_path if both provided. Supports http/https URLs.' },
image_search: { type: 'string', description: 'Search keyword to find an image automatically (e.g. "golden retriever", "mountain sunset"). Overrides image_url if both provided. Images are downloaded before slides are created.' },
img_filter: { type: 'string', description: 'Image effect filter: "grayscale" (흑백), "blur" (블러), "contrast" (대비 강화), "bright" (밝게), "round" (둥근 모서리), or "none". Applied to the slide image via Pillow before embedding.' },
img_blur_radius: { type: 'number', description: 'Blur radius when img_filter="blur" (default: 5, range: 1-20)' },
img_corner_radius: { type: 'number', description: 'Corner radius % when img_filter="round" (default: 15, range: 5-50)' },
background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` },
template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` },
notes: { type: 'string', description: 'Speaker notes' },
+42 -12
View File
@@ -348,8 +348,7 @@ export const pubmedFulltextTool = {
};
}
// Download PDF
const config = getConfig().getConfig();
// Download PDF — try Unpaywall URL first, then Europe PMC as fallback
const workspaceDir = getWorkspacePath(args);
const pdfDir = path.join(workspaceDir, 'pubmed');
fs.mkdirSync(pdfDir, { recursive: true });
@@ -358,16 +357,47 @@ export const pubmedFulltextTool = {
? path.join(workspaceDir, args.save_path)
: path.join(pdfDir, `${pmcid}.pdf`);
const pdfRes = await fetch(pdfUrl, {
signal: AbortSignal.timeout(60_000),
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SmallClaw/1.0; PubMed research bot)' },
});
if (!pdfRes.ok) {
return { success: false, error: `PDF download failed: HTTP ${pdfRes.status} from ${pdfUrl}` };
}
const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
fs.writeFileSync(pdfDest, pdfBuf);
const europePmcUrl = `https://europepmc.org/api/getPdf?pmcid=${pmcid}`;
const urlsToTry = [pdfUrl, europePmcUrl].filter(Boolean);
let pdfBuf: Buffer | null = null;
let usedUrl = '';
for (const tryUrl of urlsToTry) {
try {
const res = await fetch(tryUrl, {
signal: AbortSignal.timeout(60_000),
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Accept': 'application/pdf,*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://europepmc.org/',
},
});
if (!res.ok) continue;
const buf = Buffer.from(await res.arrayBuffer());
// Reject HTML responses (PMC sometimes returns HTML instead of PDF)
if (!buf.slice(0, 5).toString('ascii').startsWith('%PDF')) {
console.log(`[pubmed] ${tryUrl} returned non-PDF content (HTML?), trying next`);
continue;
}
pdfBuf = buf;
usedUrl = tryUrl;
break;
} catch {}
}
if (!pdfBuf) {
return {
success: false,
error: [
`PDF download failed for ${pmcid}: all sources returned non-PDF content.`,
`Tried: ${urlsToTry.join(', ')}`,
`Try format "text" to get the plain-text version instead.`,
].join('\n'),
};
}
fs.writeFileSync(pdfDest, pdfBuf);
const sizeKb = (pdfBuf.length / 1024).toFixed(1);
const relPath = path.relative(workspaceDir, pdfDest);
return {
@@ -378,7 +408,7 @@ export const pubmedFulltextTool = {
`Saved to: ${relPath} (${sizeKb} KB)`,
`File link: /api/files/${relPath}`,
].filter(Boolean).join('\n'),
data: { pmcid, doi, pdf_url: pdfUrl, save_path: pdfDest, size_kb: Number(sizeKb) },
data: { pmcid, doi, pdf_url: usedUrl, save_path: pdfDest, size_kb: Number(sizeKb) },
};
}
} catch (oaErr: any) {
+5 -3
View File
@@ -81,7 +81,7 @@ export const openalexSearchTool = {
'per-page': String(limit),
sort: sortKey,
filter: filters.join(','),
select: 'id,doi,display_name,publication_year,cited_by_count,open_access,authorships,abstract_inverted_index,primary_location',
select: 'id,doi,display_name,publication_year,cited_by_count,open_access,best_oa_location,authorships,abstract_inverted_index,primary_location',
mailto: POLITE_EMAIL,
});
@@ -111,7 +111,7 @@ export const openalexSearchTool = {
.join(', ') + ((r.authorships || []).length > 6 ? ' et al.' : '');
const citations = r.cited_by_count ?? 0;
const isOa = r.open_access?.is_oa ?? false;
const oaUrl = r.open_access?.oa_url || '';
const oaUrl = r.best_oa_location?.pdf_url || r.open_access?.oa_url || '';
const abstract = reconstructAbstract(r.abstract_inverted_index).slice(0, 400);
lines.push(`[${i + 1}] ${title}`);
@@ -132,7 +132,8 @@ export const openalexSearchTool = {
doi: r.doi ? String(r.doi).replace('https://doi.org/', '') : '',
citations: r.cited_by_count,
is_oa: r.open_access?.is_oa,
oa_url: r.open_access?.oa_url,
oa_url: r.best_oa_location?.pdf_url || null,
abstract: reconstructAbstract(r.abstract_inverted_index).slice(0, 2000),
authors: (r.authorships || []).slice(0, 6).map((a: any) => a?.author?.display_name).filter(Boolean),
})) },
};
@@ -243,6 +244,7 @@ export const semanticSearchTool = {
citations: p.citationCount,
inf_citations: p.influentialCitationCount,
oa_pdf: p.openAccessPdf?.url,
abstract: String(p.abstract || '').slice(0, 2000),
authors: (p.authors || []).slice(0, 6).map((a: any) => a.name),
})) },
};