|
|
|
@@ -10,8 +10,8 @@ type SSESender = (type: string, data: any) => void;
|
|
|
|
|
const PYTHON_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'pptx_gen.py');
|
|
|
|
|
|
|
|
|
|
// ─── Paths ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
const SKIN_DIR = path.join(__dirname, '..', '..', 'ppt', 'skin');
|
|
|
|
|
const TEMPLATE_DIR = path.join(__dirname, '..', '..', 'ppt', 'template');
|
|
|
|
|
const SKIN_DIR = path.join(__dirname, '..', '..', '.smallclaw', 'skills', 'presenter', 'ppt', 'skin');
|
|
|
|
|
const TEMPLATE_DIR = path.join(__dirname, '..', '..', '.smallclaw', 'skills', 'presenter', 'ppt', 'template');
|
|
|
|
|
const LEGACY_SKIN_DIR = path.join(__dirname, '..', '..', 'assets', 'pptx_template');
|
|
|
|
|
|
|
|
|
|
// Resolve skin directory: prefer ppt/skin, fall back to legacy assets/pptx_template
|
|
|
|
@@ -125,8 +125,48 @@ function repairJson(input: string): string {
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SLIDE_TYPES = new Set(['title', 'content', 'section', 'image', 'table', 'chart', 'timeline', 'blank']);
|
|
|
|
|
|
|
|
|
|
/** Normalize a single slide spec to handle field-name variants from different LLMs. */
|
|
|
|
|
function normalizeSlide(slide: any): any {
|
|
|
|
|
const s = { ...slide };
|
|
|
|
|
// 1. Infer `type` from `layout` when missing (Kimi uses layout="chart" / layout="table")
|
|
|
|
|
if (!s.type && s.layout && SLIDE_TYPES.has(s.layout.toLowerCase())) {
|
|
|
|
|
s.type = s.layout.toLowerCase();
|
|
|
|
|
delete s.layout;
|
|
|
|
|
}
|
|
|
|
|
// 2. camelCase chartType → chart_type
|
|
|
|
|
if (s.chartType !== undefined && s.chart_type === undefined) {
|
|
|
|
|
s.chart_type = s.chartType;
|
|
|
|
|
}
|
|
|
|
|
// 3. series[].data → series[].values
|
|
|
|
|
if (Array.isArray(s.series)) {
|
|
|
|
|
s.series = s.series.map((sr: any) => {
|
|
|
|
|
if (sr.data !== undefined && sr.values === undefined) {
|
|
|
|
|
return { ...sr, values: sr.data };
|
|
|
|
|
}
|
|
|
|
|
return sr;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// 4. Nested table object → flatten (table / table_data both supported)
|
|
|
|
|
for (const tblKey of ['table', 'table_data']) {
|
|
|
|
|
const tbl = s[tblKey];
|
|
|
|
|
if (tbl && typeof tbl === 'object' && !Array.isArray(tbl)) {
|
|
|
|
|
if (s.headers === undefined && tbl.headers !== undefined) s.headers = tbl.headers;
|
|
|
|
|
if (s.rows === undefined && tbl.rows !== undefined) s.rows = tbl.rows;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 5. content field is a list → treat as bullets
|
|
|
|
|
if (Array.isArray(s.content) && s.bullets === undefined) {
|
|
|
|
|
s.bullets = s.content.map((b: any) => String(b));
|
|
|
|
|
delete s.content;
|
|
|
|
|
}
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Generate PPTX using python-pptx via the scripts/pptx_gen.py script. */
|
|
|
|
|
async function generateWithPython(spec: PresentationSpec, workspacePath: string): Promise<ToolResult> {
|
|
|
|
|
spec = { ...spec, slides: spec.slides.map(normalizeSlide) };
|
|
|
|
|
const tmpSpecPath = path.join(workspacePath, `.pptx_spec_${Date.now()}.json`);
|
|
|
|
|
try {
|
|
|
|
|
// Write spec to temp file
|
|
|
|
@@ -216,6 +256,26 @@ interface SlideSpec {
|
|
|
|
|
template?: string;
|
|
|
|
|
layout?: string;
|
|
|
|
|
notes?: string;
|
|
|
|
|
// Table slide
|
|
|
|
|
headers?: string[];
|
|
|
|
|
rows?: string[][];
|
|
|
|
|
// Chart slide
|
|
|
|
|
chart_type?: string;
|
|
|
|
|
categories?: string[];
|
|
|
|
|
series?: { name: string; values: number[] }[];
|
|
|
|
|
// Compare layout
|
|
|
|
|
left_title?: string;
|
|
|
|
|
left_bullets?: string[];
|
|
|
|
|
left_points?: string[];
|
|
|
|
|
left_body?: string;
|
|
|
|
|
right_title?: string;
|
|
|
|
|
right_bullets?: string[];
|
|
|
|
|
right_points?: string[];
|
|
|
|
|
right_body?: string;
|
|
|
|
|
// Timeline slide
|
|
|
|
|
events?: { year?: string; label?: string; text?: string; description?: string }[];
|
|
|
|
|
// Edit mode: 1-based slide number to replace (used with edit_presentation)
|
|
|
|
|
replace_index?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Image Search & Download ────────────────────────────────────────────────────
|
|
|
|
@@ -538,7 +598,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. Put ALL slides in ONE call — never split across multiple calls. NEVER write Python scripts to create PPTX. Missing images become light-gray placeholders.',
|
|
|
|
|
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 }] }',
|
|
|
|
|
},
|
|
|
|
@@ -560,8 +620,8 @@ export const pptxTool: import('./registry.js').Tool = {
|
|
|
|
|
items: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", or "blank"' },
|
|
|
|
|
layout: { type: 'string', description: 'Column layout: "split-right" (default: small title top, text left, image right), "split-left" (small title top, image left, text right), "text" (full-width text only), "fullscreen" (image fills slide, title/text overlaid at bottom)' },
|
|
|
|
|
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", "table", "chart", "timeline", or "blank"' },
|
|
|
|
|
layout: { type: 'string', description: 'Column layout (content slides): "split-right" (default: text left, image right), "split-left" (image left, text right), "text" (full-width text only), "fullscreen" (image fills slide, text overlaid), "compare" (two side-by-side text columns, no image)' },
|
|
|
|
|
title: { type: 'string', description: 'Slide title text' },
|
|
|
|
|
subtitle: { type: 'string', description: 'Subtitle (for title slides)' },
|
|
|
|
|
bullets: { type: 'array', items: { type: 'string' }, description: 'Bullet point texts' },
|
|
|
|
@@ -575,6 +635,18 @@ export const pptxTool: import('./registry.js').Tool = {
|
|
|
|
|
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' },
|
|
|
|
|
headers: { type: 'array', items: { type: 'string' }, description: 'Table header row (type="table"). E.g. ["항목","2024년","2025년"]' },
|
|
|
|
|
rows: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Table data rows (type="table"). E.g. [["매출","100M","120M"],["비용","80M","90M"]]' },
|
|
|
|
|
chart_type: { type: 'string', description: 'Chart type (type="chart"): "column" (default), "bar", "line", "line_markers", "pie", "doughnut", "column_stacked", "bar_stacked"' },
|
|
|
|
|
categories: { type: 'array', items: { type: 'string' }, description: 'Chart X-axis categories (type="chart"). E.g. ["Q1","Q2","Q3","Q4"]' },
|
|
|
|
|
series: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } } } }, description: 'Chart data series (type="chart"). E.g. [{"name":"2024","values":[10,20,30,40]}]' },
|
|
|
|
|
left_title: { type: 'string', description: 'Left column heading (layout="compare")' },
|
|
|
|
|
left_bullets: { type: 'array', items: { type: 'string' }, description: 'Left column bullet points (layout="compare")' },
|
|
|
|
|
left_body: { type: 'string', description: 'Left column body text (layout="compare")' },
|
|
|
|
|
right_title: { type: 'string', description: 'Right column heading (layout="compare")' },
|
|
|
|
|
right_bullets: { type: 'array', items: { type: 'string' }, description: 'Right column bullet points (layout="compare")' },
|
|
|
|
|
right_body: { type: 'string', description: 'Right column body text (layout="compare")' },
|
|
|
|
|
events: { type: 'array', items: { type: 'object', properties: { year: { type: 'string' }, label: { type: 'string' }, text: { type: 'string' }, description: { type: 'string' } } }, description: 'Timeline events (type="timeline"). Each: {year/label: "2024 Q1", text/description: "핵심 이벤트"}. Odd-indexed events appear below the line.' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
@@ -770,7 +842,7 @@ 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. Provide the path and new slides to add. 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. 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 }] }',
|
|
|
|
@@ -790,8 +862,8 @@ export const editPptxTool: import('./registry.js').Tool = {
|
|
|
|
|
items: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", or "blank"' },
|
|
|
|
|
layout: { type: 'string', description: 'Column layout: "split-right" (default: small title top, text left, image right), "split-left" (small title top, image left, text right), "text" (full-width text only), "fullscreen" (image fills slide, title/text overlaid at bottom)' },
|
|
|
|
|
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", "table", "chart", "timeline", or "blank"' },
|
|
|
|
|
layout: { type: 'string', description: 'Column layout (content slides): "split-right" (default: text left, image right), "split-left" (image left, text right), "text" (full-width text only), "fullscreen" (image fills slide, text overlaid), "compare" (two side-by-side text columns, no image)' },
|
|
|
|
|
title: { type: 'string', description: 'Slide title text' },
|
|
|
|
|
subtitle: { type: 'string', description: 'Subtitle (for title slides)' },
|
|
|
|
|
bullets: { type: 'array', items: { type: 'string' }, description: 'Bullet point texts' },
|
|
|
|
@@ -805,6 +877,19 @@ export const editPptxTool: import('./registry.js').Tool = {
|
|
|
|
|
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' },
|
|
|
|
|
headers: { type: 'array', items: { type: 'string' }, description: 'Table header row (type="table"). E.g. ["항목","2024년","2025년"]' },
|
|
|
|
|
rows: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Table data rows (type="table"). E.g. [["매출","100M","120M"],["비용","80M","90M"]]' },
|
|
|
|
|
chart_type: { type: 'string', description: 'Chart type (type="chart"): "column" (default), "bar", "line", "line_markers", "pie", "doughnut", "column_stacked", "bar_stacked"' },
|
|
|
|
|
categories: { type: 'array', items: { type: 'string' }, description: 'Chart X-axis categories (type="chart"). E.g. ["Q1","Q2","Q3","Q4"]' },
|
|
|
|
|
series: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } } } }, description: 'Chart data series (type="chart"). E.g. [{"name":"2024","values":[10,20,30,40]}]' },
|
|
|
|
|
left_title: { type: 'string', description: 'Left column heading (layout="compare")' },
|
|
|
|
|
left_bullets: { type: 'array', items: { type: 'string' }, description: 'Left column bullet points (layout="compare")' },
|
|
|
|
|
left_body: { type: 'string', description: 'Left column body text (layout="compare")' },
|
|
|
|
|
right_title: { type: 'string', description: 'Right column heading (layout="compare")' },
|
|
|
|
|
right_bullets: { type: 'array', items: { type: 'string' }, description: 'Right column bullet points (layout="compare")' },
|
|
|
|
|
right_body: { type: 'string', description: 'Right column body text (layout="compare")' },
|
|
|
|
|
events: { type: 'array', items: { type: 'object', properties: { year: { type: 'string' }, label: { type: 'string' }, text: { type: 'string' }, description: { type: 'string' } } }, description: 'Timeline events (type="timeline"). Each: {year/label: "2024 Q1", text/description: "핵심 이벤트"}. Odd-indexed events appear below the line.' },
|
|
|
|
|
replace_index: { type: 'number', description: '1-based slide number to replace. Removes the existing slide at that position and inserts the new one there. Omit to append.' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
@@ -831,7 +916,29 @@ export const editPptxTool: import('./registry.js').Tool = {
|
|
|
|
|
: path.join(workspacePath, existingPath);
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(absPath)) {
|
|
|
|
|
return { success: false, error: `PPTX file not found: ${absPath}` };
|
|
|
|
|
// Fuzzy fallback: find a PPTX whose folder name starts with the given folder prefix.
|
|
|
|
|
// Handles cases where the model truncates the slug (e.g. "투명교정의_최신_술식" →
|
|
|
|
|
// actual folder "투명교정의_최신_술식_및_기술").
|
|
|
|
|
const givenFolder = path.basename(path.dirname(absPath));
|
|
|
|
|
let fuzzyMatch: string | null = null;
|
|
|
|
|
try {
|
|
|
|
|
for (const entry of fs.readdirSync(workspacePath)) {
|
|
|
|
|
if (!entry.startsWith(givenFolder) || entry === givenFolder) continue;
|
|
|
|
|
const candidate = path.join(workspacePath, entry);
|
|
|
|
|
if (!fs.statSync(candidate).isDirectory()) continue;
|
|
|
|
|
const pptxFiles = fs.readdirSync(candidate).filter(f => f.toLowerCase().endsWith('.pptx'));
|
|
|
|
|
if (pptxFiles.length > 0) {
|
|
|
|
|
fuzzyMatch = path.join(candidate, pptxFiles[0]);
|
|
|
|
|
console.log(`[pptx] edit_presentation: fuzzy match "${existingPath}" → "${fuzzyMatch}"`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
if (!fuzzyMatch) {
|
|
|
|
|
return { success: false, error: `PPTX file not found: ${absPath}` };
|
|
|
|
|
}
|
|
|
|
|
// Use the fuzzy-matched path from here on
|
|
|
|
|
(args as any)._resolvedPath = fuzzyMatch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let spec: PresentationSpec;
|
|
|
|
@@ -844,6 +951,10 @@ export const editPptxTool: import('./registry.js').Tool = {
|
|
|
|
|
return { success: false, error: 'spec.slides must be a non-empty array' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inject config defaults so Python engine inherits the same skin/template as create_presentation
|
|
|
|
|
if (!spec.template && config.ppt?.template) spec.template = config.ppt.template;
|
|
|
|
|
if (!spec.default_skin && config.ppt?.skin) spec.default_skin = config.ppt.skin;
|
|
|
|
|
|
|
|
|
|
// Resolve image API keys
|
|
|
|
|
const unsplashKey = process.env.UNSPLASH_ACCESS_KEY || configMgr.resolveSecret(config.ppt?.unsplash_key) || '';
|
|
|
|
|
const pexelsKey = process.env.PEXELS_API_KEY || configMgr.resolveSecret(config.ppt?.pexels_key) || '';
|
|
|
|
@@ -857,7 +968,8 @@ export const editPptxTool: import('./registry.js').Tool = {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Download all images into the PPTX directory ──
|
|
|
|
|
const editProjectDir = path.dirname(absPath);
|
|
|
|
|
const resolvedAbsPath: string = (args as any)._resolvedPath || absPath;
|
|
|
|
|
const editProjectDir = path.dirname(resolvedAbsPath);
|
|
|
|
|
const sendSSE = (args as any)?._sendSSE as SSESender | undefined;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < spec.slides.length; i++) {
|
|
|
|
@@ -891,7 +1003,7 @@ export const editPptxTool: import('./registry.js').Tool = {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pass existing_path to Python engine
|
|
|
|
|
(spec as any).existing_path = absPath;
|
|
|
|
|
(spec as any).existing_path = resolvedAbsPath;
|
|
|
|
|
return await generateWithPython(spec, workspacePath);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|