hanseol-dev 5.0.2-dev.99 → 5.0.3-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import { powerpointClient } from '../../tools/office/powerpoint-client.js';
4
4
  import { getSubAgentPhaseLogger, getSubAgentToolCallLogger } from '../common/sub-agent.js';
5
5
  import { logger } from '../../utils/logger.js';
6
6
  import { getPlatform } from '../../utils/platform-utils.js';
7
- import { PPT_CREATE_ENHANCEMENT_PROMPT, PPT_STRUCTURED_PLANNING_PROMPT, buildSlideHtmlPrompt, } from './powerpoint-create-prompts.js';
7
+ import { PPT_DESIGN_PROMPT, buildDirectHtmlPrompt, validateSlideHtml, extractLayoutHint, buildContentFillJsonPrompt, parseContentFillJson, buildContentSlideHtml, } from './powerpoint-create-prompts.js';
8
8
  const DEFAULT_DESIGN = {
9
9
  primary_color: '#1B2A4A',
10
10
  accent_color: '#00D4AA',
@@ -17,6 +17,7 @@ const DEFAULT_DESIGN = {
17
17
  mood: 'modern-minimal',
18
18
  design_notes: 'Clean gradients, card-based layouts',
19
19
  };
20
+ const MAX_CONCURRENT = 3;
20
21
  function extractContent(msg) {
21
22
  const content = msg['content'];
22
23
  if (content && content.trim())
@@ -26,6 +27,23 @@ function extractContent(msg) {
26
27
  return reasoning;
27
28
  return '';
28
29
  }
30
+ function hasPlaceholderText(html) {
31
+ const placeholderPatterns = [
32
+ 'Card title (2-5 words)',
33
+ 'Detail with number/data',
34
+ 'single emoji',
35
+ 'Display value (e.g.',
36
+ 'Category name',
37
+ '1-2 sentence key insight',
38
+ 'Another detail',
39
+ 'Third point',
40
+ 'Fourth point',
41
+ 'Brief context',
42
+ 'Segment name',
43
+ ];
44
+ const lowerHtml = html.toLowerCase();
45
+ return placeholderPatterns.some(p => lowerHtml.includes(p.toLowerCase()));
46
+ }
29
47
  function validateAndFixPlan(plan) {
30
48
  if (!plan.design)
31
49
  return 'Missing design object';
@@ -35,6 +53,9 @@ function validateAndFixPlan(plan) {
35
53
  if (!Array.isArray(plan.slides) || plan.slides.length < 3) {
36
54
  return 'slides array must have at least 3 entries';
37
55
  }
56
+ if (plan.slides.length < 10) {
57
+ return `Only ${plan.slides.length} slides — minimum 10 required (aim for 10-12). Add more content slides with specific data.`;
58
+ }
38
59
  if (plan.slides[0]?.type !== 'title') {
39
60
  logger.info('Auto-fixing: first slide type changed to "title"');
40
61
  plan.slides[0].type = 'title';
@@ -47,7 +68,23 @@ function validateAndFixPlan(plan) {
47
68
  for (let i = 0; i < plan.slides.length; i++) {
48
69
  if (!plan.slides[i].title) {
49
70
  plan.slides[i].title = `Slide ${i + 1}`;
50
- logger.info(`Auto-fixing: slide ${i + 1} missing title, set placeholder`);
71
+ }
72
+ }
73
+ const layoutOnlyPatterns = [
74
+ /^(?:전체\s*배경|왼쪽에|오른쪽에|중앙에|상단에|하단에)/,
75
+ /#[0-9a-fA-F]{3,8}에서.*그라데이션/,
76
+ /(?:accent_light|primary|gradient_end)\s*(?:배경|글씨|색상)/,
77
+ /^(?:CSS|flexbox|grid|conic-gradient|linear-gradient)/i,
78
+ ];
79
+ for (let i = 0; i < plan.slides.length; i++) {
80
+ const slide = plan.slides[i];
81
+ if (slide.type === 'title' || slide.type === 'closing')
82
+ continue;
83
+ const cd = slide.content_direction || '';
84
+ const hasNumbers = /\d/.test(cd);
85
+ const isLayoutOnly = layoutOnlyPatterns.some(p => p.test(cd));
86
+ if (isLayoutOnly && !hasNumbers) {
87
+ return `Slide ${i + 1} "${slide.title}" content_direction contains layout instructions instead of actual data.`;
51
88
  }
52
89
  }
53
90
  return null;
@@ -130,19 +167,15 @@ function parseJsonPlan(raw) {
130
167
  cleaned = cleaned.replace(/^```(?:json|JSON)?\s*\n?/, '').replace(/\n?```\s*$/, '');
131
168
  }
132
169
  const firstBrace = cleaned.indexOf('{');
133
- if (firstBrace > 0) {
170
+ if (firstBrace > 0)
134
171
  cleaned = cleaned.slice(firstBrace);
135
- }
136
172
  const lastBrace = cleaned.lastIndexOf('}');
137
- if (lastBrace >= 0 && lastBrace < cleaned.length - 1) {
173
+ if (lastBrace >= 0 && lastBrace < cleaned.length - 1)
138
174
  cleaned = cleaned.slice(0, lastBrace + 1);
139
- }
140
175
  try {
141
176
  return JSON.parse(cleaned);
142
177
  }
143
- catch (e) {
144
- logger.debug('parseJsonPlan: direct parse failed', { error: String(e), length: cleaned.length });
145
- }
178
+ catch { }
146
179
  const match = cleaned.match(/\{[\s\S]*\}/);
147
180
  if (!match)
148
181
  return null;
@@ -154,9 +187,7 @@ function parseJsonPlan(raw) {
154
187
  try {
155
188
  return JSON.parse(repaired);
156
189
  }
157
- catch (e) {
158
- logger.debug('parseJsonPlan: repaired parse failed', { error: String(e) });
159
- }
190
+ catch { }
160
191
  try {
161
192
  let final = repaired;
162
193
  let braces = 0, brackets = 0;
@@ -187,9 +218,9 @@ function parseJsonPlan(raw) {
187
218
  }
188
219
  if (inStr)
189
220
  final += '"';
190
- for (let i = 0; i < brackets; i++)
221
+ for (let x = 0; x < brackets; x++)
191
222
  final += ']';
192
- for (let i = 0; i < braces; i++)
223
+ for (let x = 0; x < braces; x++)
193
224
  final += '}';
194
225
  return JSON.parse(final);
195
226
  }
@@ -199,69 +230,50 @@ function parseJsonPlan(raw) {
199
230
  }
200
231
  function extractHtml(raw) {
201
232
  const trimmed = raw.trim();
202
- if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
233
+ if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html'))
203
234
  return trimmed;
204
- }
205
235
  const fenceMatch = trimmed.match(/```(?:html)?\s*\n([\s\S]*?)\n```/);
206
- if (fenceMatch?.[1]) {
236
+ if (fenceMatch?.[1])
207
237
  return fenceMatch[1].trim();
208
- }
209
238
  const docMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i);
210
- if (docMatch?.[1]) {
239
+ if (docMatch?.[1])
211
240
  return docMatch[1].trim();
212
- }
213
241
  return null;
214
242
  }
215
- function injectViewportCss(html, backgroundColor) {
243
+ function injectEdgeSizing(html, backgroundColor) {
216
244
  let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
217
245
  const bgColor = backgroundColor || '#000000';
218
- const viewportMeta = `<meta name="viewport" content="width=2040">`;
219
- const overrideCss = `<style>*{box-sizing:border-box;word-break:keep-all;overflow-wrap:break-word}html{width:2040px!important;height:1200px!important;overflow:hidden!important;margin:0!important;background-color:${bgColor}!important;zoom:1!important}body{width:1920px!important;height:1080px!important;min-width:1920px!important;min-height:1080px!important;overflow:hidden!important;margin:0!important;display:flex!important;flex-direction:column!important;zoom:1!important}body>div,body>main,body>section,body>article,body>header,body>footer{max-width:none!important;zoom:1!important}body>*>div,body>*>main,body>*>section,body>*>article{max-width:none!important;zoom:1!important}body>*:nth-child(2){flex:1!important;align-items:stretch!important;align-content:stretch!important;justify-content:center!important}</style>`;
220
- const injection = viewportMeta + overrideCss;
246
+ const sizingCss = `<style id="edge-sizing">html{width:2040px!important;height:1200px!important;overflow:hidden!important;margin:0!important;background-color:${bgColor}!important;zoom:1!important}body{width:1920px!important;height:1080px!important;min-width:1920px!important;min-height:1080px!important;overflow:hidden!important;margin:0!important;zoom:1!important}</style>`;
221
247
  if (result.includes('</head>')) {
222
- result = result.replace('</head>', `${injection}</head>`);
248
+ result = result.replace('</head>', `${sizingCss}</head>`);
223
249
  }
224
250
  else if (result.includes('<head>')) {
225
- result = result.replace('<head>', `<head>${injection}`);
251
+ result = result.replace('<head>', `<head>${sizingCss}`);
226
252
  }
227
253
  else if (result.includes('<html')) {
228
- result = result.replace(/<html[^>]*>/, (match) => `${match}<head>${injection}</head>`);
254
+ result = result.replace(/<html[^>]*>/, (m) => `${m}<head>${sizingCss}</head>`);
229
255
  }
230
256
  else {
231
- result = injection + result;
257
+ result = sizingCss + result;
232
258
  }
233
259
  return result;
234
260
  }
235
- function enforceMinFontSize(html) {
236
- let result = html.replace(/font-size:\s*(\d+(?:\.\d+)?)px/g, (_match, size) => {
237
- const px = parseFloat(size);
238
- return (px > 12 && px < 22) ? 'font-size:22px' : _match;
239
- });
240
- result = result.replace(/font-size:\s*(\d+(?:\.\d+)?)pt/g, (_match, size) => {
241
- const pt = parseFloat(size);
242
- const px = pt * 1.333;
243
- return (px > 12 && px < 22) ? 'font-size:22px' : _match;
244
- });
245
- return result;
246
- }
247
- function ensureSafeBodyPadding(html) {
248
- const safeStyle = `<style>body{padding-top:20px!important;padding-bottom:40px!important;min-height:auto!important}body>div:last-child,body>section:last-child{margin-bottom:0!important;padding-bottom:0!important}</style>`;
249
- if (html.includes('</head>')) {
250
- return html.replace('</head>', `${safeStyle}</head>`);
261
+ function injectViewportCss(html, backgroundColor) {
262
+ let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
263
+ const bgColor = backgroundColor || '#000000';
264
+ const overrideCss = `<style>*{box-sizing:border-box;word-break:keep-all;overflow-wrap:break-word}html{width:2040px!important;height:1200px!important;overflow:hidden!important;margin:0!important;background-color:${bgColor}!important;zoom:1!important}body{width:1920px!important;height:1080px!important;min-width:1920px!important;min-height:1080px!important;overflow:hidden!important;margin:0!important;display:flex!important;flex-direction:column!important;zoom:1!important;font-size:26px!important}body>div,body>main,body>section,body>article,body>header,body>footer{max-width:none!important;zoom:1!important}body>*>div,body>*>main,body>*>section,body>*>article{max-width:none!important;zoom:1!important}body>*:only-child{display:flex!important;flex-direction:column!important;flex:1!important;min-height:1080px!important}body>*:only-child>*:nth-child(2),body>*:only-child>*:nth-child(3),body>*:only-child>*:last-child:not(:first-child){flex:1!important;display:flex!important;align-items:stretch!important}body>.content,body>*:nth-child(2),body>*:nth-child(3),body *>.content,body [class*=content]{flex:1!important;align-items:stretch!important;align-content:stretch!important;grid-auto-rows:1fr!important}[class*=card],[class*=item],[class*=step],[class*=milestone],[class*=feature]{justify-content:flex-start!important;gap:20px!important}</style>`;
265
+ if (result.includes('</head>')) {
266
+ result = result.replace('</head>', `${overrideCss}</head>`);
267
+ }
268
+ else if (result.includes('<head>')) {
269
+ result = result.replace('<head>', `<head>${overrideCss}`);
270
+ }
271
+ else if (result.includes('<html')) {
272
+ result = result.replace(/<html[^>]*>/, (m) => `${m}<head>${overrideCss}</head>`);
273
+ }
274
+ else {
275
+ result = overrideCss + result;
251
276
  }
252
- return html;
253
- }
254
- function removeAbsolutePositioning(html) {
255
- let result = html.replace(/style="([^"]*)position:\s*absolute([^"]*)"/gi, (match, before, after) => {
256
- if (/(?:width|height):\s*[1-5]?\dpx/i.test(before + after) || /opacity:\s*0\.[0-4]/i.test(before + after)) {
257
- return match;
258
- }
259
- return `style="${before}position:relative${after}"`;
260
- });
261
- result = result.replace(/(\.(?:title|heading|label|text|badge|stat|value|card|item|metric)[^{]*\{[^}]*)position:\s*absolute/gi, '$1position:relative');
262
- result = result.replace(/transform:\s*scale\(\s*0\.\d+\s*\)/gi, 'transform:none');
263
- result = result.replace(/transform:\s*scale\(\s*0\.\d+\s*,\s*0\.\d+\s*\)/gi, 'transform:none');
264
- result = result.replace(/zoom:\s*0\.\d+/gi, 'zoom:1');
265
277
  return result;
266
278
  }
267
279
  function injectTitleContrastFix(html, designTextColor) {
@@ -290,40 +302,10 @@ function injectTitleContrastFix(html, designTextColor) {
290
302
  }
291
303
  return html + script;
292
304
  }
293
- function injectMeasureCss(html) {
294
- let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
295
- const viewportMeta = `<meta name="viewport" content="width=2040">`;
296
- const measureCss = `<style>*{box-sizing:border-box;word-break:keep-all;overflow-wrap:break-word}html{width:2040px!important;margin:0!important}body{width:1920px!important;min-width:1920px!important;min-height:1080px!important;margin:0!important;display:flex!important;flex-direction:column!important}body>div,body>main,body>section,body>article,body>header,body>footer{max-width:none!important}</style>`;
297
- const injection = viewportMeta + measureCss;
298
- if (result.includes('</head>')) {
299
- result = result.replace('</head>', `${injection}</head>`);
300
- }
301
- else if (result.includes('<head>')) {
302
- result = result.replace('<head>', `<head>${injection}`);
303
- }
304
- else if (result.includes('<html')) {
305
- result = result.replace(/<html[^>]*>/, (match) => `${match}<head>${injection}</head>`);
306
- }
307
- else {
308
- result = injection + result;
309
- }
310
- const measureScript = `<script>document.title='SH:'+document.documentElement.scrollHeight</script>`;
311
- if (result.includes('</body>')) {
312
- result = result.replace('</body>', `${measureScript}</body>`);
313
- }
314
- else {
315
- result += measureScript;
316
- }
317
- return result;
318
- }
319
305
  function escapeHtml(text) {
320
- return text
321
- .replace(/&/g, '&amp;')
322
- .replace(/</g, '&lt;')
323
- .replace(/>/g, '&gt;')
324
- .replace(/"/g, '&quot;');
306
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
325
307
  }
326
- function buildTitleSlideHtml(design, mainTitle, subtitle, date, slideNum) {
308
+ function buildTitleSlideHtml(design, mainTitle, subtitle, date, _slideNum) {
327
309
  return `<!DOCTYPE html>
328
310
  <html lang="ko">
329
311
  <head>
@@ -335,29 +317,21 @@ html, body { width: 1920px; height: 1080px; overflow: hidden; }
335
317
  body {
336
318
  background: linear-gradient(135deg, ${design.primary_color} 0%, ${design.gradient_end} 60%, ${design.primary_color} 100%);
337
319
  display: flex;
320
+ flex-direction: column;
338
321
  align-items: center;
339
322
  justify-content: center;
340
- position: relative;
341
323
  font-family: "${design.font_title}", "Segoe UI", sans-serif;
342
324
  }
343
- .decor {
344
- position: absolute;
345
- border-radius: 50%;
346
- background: rgba(255,255,255,0.03);
347
- pointer-events: none;
348
- }
349
- .d1 { width: 700px; height: 700px; top: -250px; right: -150px; }
350
- .d2 { width: 500px; height: 500px; bottom: -180px; left: -120px; }
351
- .d3 { width: 250px; height: 250px; top: 40%; left: 8%; background: rgba(255,255,255,0.02); }
352
- .d4 { width: 180px; height: 180px; bottom: 15%; right: 12%; background: rgba(255,255,255,0.02); }
353
- .top-accent {
354
- position: absolute;
355
- top: 0; left: 0; width: 100%; height: 6px;
325
+ body::before {
326
+ content: '';
327
+ display: block;
328
+ width: 100%;
329
+ height: 6px;
356
330
  background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
331
+ flex-shrink: 0;
357
332
  }
358
- .content {
333
+ .slide-content {
359
334
  text-align: center;
360
- z-index: 1;
361
335
  max-width: 1400px;
362
336
  padding: 0 60px;
363
337
  }
@@ -391,32 +365,22 @@ body {
391
365
  color: rgba(255,255,255,0.55);
392
366
  font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
393
367
  }
394
- .page-num {
395
- position: absolute;
396
- bottom: 24px; right: 44px;
397
- font-size: 13px;
398
- color: rgba(255,255,255,0.35);
399
- }
400
368
  </style>
401
369
  </head>
402
370
  <body>
403
- <div class="top-accent"></div>
404
- <div class="decor d1"></div>
405
- <div class="decor d2"></div>
406
- <div class="decor d3"></div>
407
- <div class="decor d4"></div>
408
- <div class="content">
371
+ <div class="slide-content">
409
372
  <div class="main-title">${escapeHtml(mainTitle)}</div>
410
373
  <div class="accent-bar"></div>
411
374
  ${subtitle ? `<div class="subtitle">${escapeHtml(subtitle)}</div>` : ''}
412
375
  <div class="date-text">${escapeHtml(date)}</div>
413
376
  </div>
414
- <div class="page-num">${slideNum}</div>
377
+ <style>body{align-items:center!important;justify-content:center!important}.slide-content{flex:unset!important;min-height:unset!important;display:flex!important;flex-direction:column!important;align-items:center!important;justify-content:center!important}.slide-content>*{flex:unset!important;min-height:unset!important}</style>
415
378
  </body>
416
379
  </html>`;
417
380
  }
418
- function buildClosingSlideHtml(design, companyName, slideNum, language) {
381
+ function buildClosingSlideHtml(design, companyName, _slideNum, language, tagline) {
419
382
  const thankYou = language === 'ko' ? '감사합니다' : 'Thank You';
383
+ const taglineHtml = tagline ? `<div class="tagline">${escapeHtml(tagline)}</div>` : '';
420
384
  return `<!DOCTYPE html>
421
385
  <html lang="${language}">
422
386
  <head>
@@ -428,75 +392,77 @@ html, body { width: 1920px; height: 1080px; overflow: hidden; }
428
392
  body {
429
393
  background: linear-gradient(135deg, ${design.primary_color} 0%, ${design.gradient_end} 60%, ${design.primary_color} 100%);
430
394
  display: flex;
395
+ flex-direction: column;
431
396
  align-items: center;
432
397
  justify-content: center;
433
- position: relative;
434
398
  font-family: "${design.font_title}", "Segoe UI", sans-serif;
435
399
  }
436
- .decor {
400
+ body::before {
401
+ content: '';
437
402
  position: absolute;
438
- border-radius: 50%;
439
- background: rgba(255,255,255,0.03);
440
- pointer-events: none;
403
+ top: 0; left: 0; right: 0;
404
+ height: 6px;
405
+ background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
441
406
  }
442
- .d1 { width: 600px; height: 600px; top: -200px; left: -150px; }
443
- .d2 { width: 400px; height: 400px; bottom: -120px; right: -80px; }
444
- .bottom-accent {
407
+ body::after {
408
+ content: '';
445
409
  position: absolute;
446
- bottom: 0; left: 0; width: 100%; height: 6px;
410
+ bottom: 0; left: 0; right: 0;
411
+ height: 6px;
447
412
  background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
448
413
  }
449
- .content {
414
+ .slide-content {
450
415
  text-align: center;
451
- z-index: 1;
416
+ max-width: 1200px;
452
417
  }
453
418
  .thank-you {
454
- font-size: 96px;
419
+ font-size: 104px;
455
420
  font-weight: 800;
456
421
  color: #ffffff;
457
422
  letter-spacing: -1px;
458
423
  text-shadow: 0 6px 40px rgba(0,0,0,0.25);
459
- margin-bottom: 32px;
424
+ margin-bottom: 36px;
460
425
  }
461
426
  .accent-bar {
462
- width: 100px; height: 5px;
427
+ width: 120px; height: 5px;
463
428
  background: ${design.accent_color};
464
- margin: 0 auto 32px;
429
+ margin: 0 auto 36px;
465
430
  border-radius: 3px;
466
431
  box-shadow: 0 0 20px ${design.accent_color}40;
467
432
  }
468
433
  .company {
469
- font-size: 36px;
470
- font-weight: 500;
471
- color: rgba(255,255,255,0.80);
434
+ font-size: 44px;
435
+ font-weight: 600;
436
+ color: rgba(255,255,255,0.88);
472
437
  font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
438
+ margin-bottom: 20px;
473
439
  }
474
- .page-num {
475
- position: absolute;
476
- bottom: 24px; right: 44px;
477
- font-size: 13px;
478
- color: rgba(255,255,255,0.35);
440
+ .tagline {
441
+ font-size: 28px;
442
+ font-weight: 400;
443
+ color: rgba(255,255,255,0.60);
444
+ font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
445
+ line-height: 1.6;
446
+ max-width: 900px;
447
+ margin: 0 auto;
479
448
  }
480
449
  </style>
481
450
  </head>
482
451
  <body>
483
- <div class="decor d1"></div>
484
- <div class="decor d2"></div>
485
- <div class="bottom-accent"></div>
486
- <div class="content">
452
+ <div class="slide-content">
487
453
  <div class="thank-you">${escapeHtml(thankYou)}</div>
488
454
  <div class="accent-bar"></div>
489
455
  <div class="company">${escapeHtml(companyName)}</div>
456
+ ${taglineHtml}
490
457
  </div>
491
- <div class="page-num">${slideNum}</div>
458
+ <style>body{align-items:center!important;justify-content:center!important}.slide-content{flex:unset!important;min-height:unset!important;display:flex!important;flex-direction:column!important;align-items:center!important;justify-content:center!important}.slide-content>*{flex:unset!important;min-height:unset!important}</style>
492
459
  </body>
493
460
  </html>`;
494
461
  }
495
462
  function isOverviewSlide(title, slideIndex) {
496
463
  if (slideIndex !== 1)
497
464
  return false;
498
- const overviewKeywords = /개요|목차|overview|agenda|outline|순서|발표\s*구성|contents|목록/i;
499
- return overviewKeywords.test(title);
465
+ return /개요|목차|overview|agenda|outline|순서|발표\s*구성|contents|목록/i.test(title);
500
466
  }
501
467
  function parseOverviewItems(contentDirection) {
502
468
  const items = [];
@@ -520,11 +486,8 @@ function parseOverviewItems(contentDirection) {
520
486
  }
521
487
  function buildOverviewSlideHtml(design, title, subtitle, items, slideNum) {
522
488
  const badgeColors = [
523
- design.primary_color,
524
- design.accent_color,
525
- design.gradient_end,
526
- design.primary_color,
527
- design.accent_color,
489
+ design.primary_color, design.accent_color, design.gradient_end,
490
+ design.primary_color, design.accent_color,
528
491
  ];
529
492
  const itemCount = items.length;
530
493
  const topRow = itemCount <= 3 ? items : items.slice(0, Math.ceil(itemCount / 2));
@@ -584,7 +547,6 @@ body {
584
547
  display: flex; flex-direction: column;
585
548
  align-items: center; text-align: center;
586
549
  gap: 12px;
587
- transition: none;
588
550
  }
589
551
  .badge {
590
552
  width: 44px; height: 44px;
@@ -653,73 +615,26 @@ function normalizeDesign(raw) {
653
615
  design_notes: raw['design_notes'] || DEFAULT_DESIGN.design_notes,
654
616
  };
655
617
  }
656
- async function runStructured(llmClient, instruction, explicitSavePath) {
657
- const startTime = Date.now();
658
- const phaseLogger = getSubAgentPhaseLogger();
659
- const toolCallLogger = getSubAgentToolCallLogger();
660
- let totalToolCalls = 0;
661
- const timestamp = Date.now();
662
- logger.enter('PPT-Create.runStructured');
663
- const hasKorean = /[\uac00-\ud7af\u1100-\u11ff]/.test(instruction);
664
- const language = hasKorean ? 'ko' : 'en';
665
- if (phaseLogger)
666
- phaseLogger('powerpoint-create', 'enhancement', 'Generating creative guidance...');
667
- let guidance = '';
668
- try {
669
- const enhRes = await llmClient.chatCompletion({
670
- messages: [
671
- { role: 'system', content: PPT_CREATE_ENHANCEMENT_PROMPT },
672
- { role: 'user', content: instruction },
673
- ],
674
- temperature: 0.7,
675
- max_tokens: 2000,
676
- });
677
- const enhMsg = enhRes.choices[0]?.message;
678
- guidance = enhMsg ? extractContent(enhMsg) : '';
679
- if (guidance.length < 500) {
680
- logger.warn('PPT enhancement too short, retrying', { length: guidance.length });
681
- const retryEnhRes = await llmClient.chatCompletion({
682
- messages: [
683
- { role: 'system', content: PPT_CREATE_ENHANCEMENT_PROMPT },
684
- { role: 'user', content: `${instruction}\n\nIMPORTANT: Produce a COMPLETE, DETAILED design system and content plan. Include ALL 7 sections: DOCUMENT_TYPE, AUDIENCE, MOOD, COLOR PALETTE (6 hex values), FONTS, SLIDE PLAN (10-15 slides), DESIGN NOTES. Do NOT stop early.` },
685
- ],
686
- temperature: 0.7,
687
- max_tokens: 2000,
688
- });
689
- const retryMsg = retryEnhRes.choices[0]?.message;
690
- const retryGuidance = retryMsg ? extractContent(retryMsg) : '';
691
- if (retryGuidance.length > guidance.length) {
692
- guidance = retryGuidance;
693
- }
694
- }
695
- if (phaseLogger)
696
- phaseLogger('powerpoint-create', 'enhancement', `Done (${guidance.length} chars)`);
697
- }
698
- catch (e) {
699
- logger.warn('PPT enhancement failed, proceeding without', { error: String(e) });
700
- }
701
- const enhancedInstruction = guidance
702
- ? `${instruction}\n\n═══ CREATIVE GUIDANCE ═══\n${guidance}\n═══ END GUIDANCE ═══`
703
- : instruction;
618
+ async function runDesignPhase(llmClient, instruction, phaseLogger) {
704
619
  if (phaseLogger)
705
- phaseLogger('powerpoint-create', 'planning', 'Generating JSON plan...');
620
+ phaseLogger('powerpoint-create', 'design', 'Generating design system + slide plan...');
706
621
  let plan = null;
707
622
  try {
708
- const planRes = await llmClient.chatCompletion({
623
+ const res = await llmClient.chatCompletion({
709
624
  messages: [
710
- { role: 'system', content: PPT_STRUCTURED_PLANNING_PROMPT },
711
- { role: 'user', content: enhancedInstruction },
625
+ { role: 'system', content: PPT_DESIGN_PROMPT },
626
+ { role: 'user', content: instruction },
712
627
  ],
713
- temperature: 0.4,
628
+ temperature: 0.5,
714
629
  max_tokens: 8000,
715
630
  });
716
- const planMsg = planRes.choices[0]?.message;
717
- const finishReason = planRes.choices[0]?.finish_reason;
718
- const rawPlan = planMsg ? extractContent(planMsg) : '';
631
+ const msg = res.choices[0]?.message;
632
+ const rawPlan = msg ? extractContent(msg) : '';
633
+ const finishReason = res.choices[0]?.finish_reason;
719
634
  if (finishReason === 'length') {
720
- logger.warn('PPT planning response was truncated (finish_reason=length)');
635
+ logger.warn('PPT design response was truncated (finish_reason=length)');
721
636
  }
722
- logger.debug('PPT planning raw response', { length: rawPlan.length, finishReason, first200: rawPlan.slice(0, 200) });
637
+ logger.debug('PPT design raw response', { length: rawPlan.length, finishReason, first200: rawPlan.slice(0, 200) });
723
638
  plan = rawPlan ? parseJsonPlan(rawPlan) : null;
724
639
  if (plan) {
725
640
  plan.design = normalizeDesign(plan.design);
@@ -727,13 +642,15 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
727
642
  if (validationError) {
728
643
  logger.warn('PPT plan validation failed', { error: validationError });
729
644
  if (phaseLogger)
730
- phaseLogger('powerpoint-create', 'planning', `Validation failed: ${validationError}. Retrying...`);
645
+ phaseLogger('powerpoint-create', 'design', `Validation failed: ${validationError}. Retrying...`);
731
646
  const retryRes = await llmClient.chatCompletion({
732
647
  messages: [
733
- { role: 'system', content: PPT_STRUCTURED_PLANNING_PROMPT },
734
- { role: 'user', content: enhancedInstruction },
648
+ { role: 'system', content: PPT_DESIGN_PROMPT },
649
+ { role: 'user', content: instruction },
650
+ { role: 'assistant', content: rawPlan },
651
+ { role: 'user', content: `ERROR: ${validationError}\n\nFix the issues and output the corrected JSON. Remember: aim for 10-12 slides with REAL content data.` },
735
652
  ],
736
- temperature: 0.2,
653
+ temperature: 0.3,
737
654
  max_tokens: 8000,
738
655
  });
739
656
  const retryMsg = retryRes.choices[0]?.message;
@@ -745,7 +662,7 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
745
662
  if (!retryError) {
746
663
  plan = retryPlan;
747
664
  if (phaseLogger)
748
- phaseLogger('powerpoint-create', 'planning', `Retry succeeded (${plan.slides.length} slides)`);
665
+ phaseLogger('powerpoint-create', 'design', `Retry succeeded (${plan.slides.length} slides)`);
749
666
  }
750
667
  else {
751
668
  plan = null;
@@ -757,199 +674,187 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
757
674
  }
758
675
  else {
759
676
  if (phaseLogger)
760
- phaseLogger('powerpoint-create', 'planning', `Done (${plan.slides.length} slides)`);
677
+ phaseLogger('powerpoint-create', 'design', `Done (${plan.slides.length} slides, mood: ${plan.design.mood})`);
761
678
  }
762
679
  }
763
- else {
764
- logger.warn('PPT JSON plan parsing failed');
765
- if (phaseLogger)
766
- phaseLogger('powerpoint-create', 'planning', 'JSON parsing failed. Falling back.');
680
+ }
681
+ catch (e) {
682
+ logger.warn('PPT design failed', { error: String(e) });
683
+ }
684
+ return plan;
685
+ }
686
+ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, totalSlides, language) {
687
+ const cleanedDirection = (slide.content_direction || '').replace(/\s*Layout\s*:\s*[^\n]*/gi, '').trim();
688
+ const layoutType = extractLayoutHint(slide.content_direction || '');
689
+ const directPrompt = buildDirectHtmlPrompt(slide.title, cleanedDirection, design, slideIndex, totalSlides, language, layoutType);
690
+ try {
691
+ const res = await llmClient.chatCompletion({
692
+ messages: [
693
+ { role: 'system', content: directPrompt },
694
+ { role: 'user', content: 'Generate the HTML slide now.' },
695
+ ],
696
+ temperature: 0.4,
697
+ max_tokens: 6000,
698
+ });
699
+ const msg = res.choices[0]?.message;
700
+ const rawHtml = msg ? extractContent(msg) : '';
701
+ const html = extractHtml(rawHtml);
702
+ if (html) {
703
+ const validation = validateSlideHtml(html, layoutType);
704
+ if (validation.pass) {
705
+ return { html, isCodeTemplate: false };
706
+ }
707
+ logger.info(`Slide ${slideIndex + 1}: Direct HTML failed validation: ${validation.feedback}`);
767
708
  }
768
709
  }
769
710
  catch (e) {
770
- logger.warn('PPT planning failed', { error: String(e) });
771
- if (phaseLogger)
772
- phaseLogger('powerpoint-create', 'planning', 'Planning error. Falling back.');
711
+ logger.warn(`Slide ${slideIndex + 1}: Direct HTML LLM call failed: ${e}`);
773
712
  }
774
- if (!plan) {
775
- logger.error('PPT planning failed after retries cannot create presentation');
776
- return {
777
- success: false,
778
- error: 'Failed to generate presentation plan. Please try again.',
779
- };
713
+ logger.info(`Slide ${slideIndex + 1}: Falling back to code template "${layoutType}"`);
714
+ const jsonPrompt = buildContentFillJsonPrompt(slide.title, cleanedDirection, layoutType, language);
715
+ try {
716
+ const jsonRes = await llmClient.chatCompletion({
717
+ messages: [
718
+ { role: 'system', content: jsonPrompt },
719
+ { role: 'user', content: 'Output the JSON now.' },
720
+ ],
721
+ temperature: 0.3,
722
+ max_tokens: 2000,
723
+ });
724
+ const jsonMsg = jsonRes.choices[0]?.message;
725
+ const jsonRaw = jsonMsg ? extractContent(jsonMsg) : '';
726
+ let slideData = parseContentFillJson(jsonRaw, layoutType);
727
+ if (!slideData) {
728
+ const retryRes = await llmClient.chatCompletion({
729
+ messages: [
730
+ { role: 'system', content: jsonPrompt },
731
+ { role: 'user', content: 'Output ONLY valid JSON. No markdown fences, no explanation. Start with { and end with }.' },
732
+ ],
733
+ temperature: 0.2,
734
+ max_tokens: 2000,
735
+ });
736
+ const retryMsg = retryRes.choices[0]?.message;
737
+ const retryRaw = retryMsg ? extractContent(retryMsg) : '';
738
+ slideData = parseContentFillJson(retryRaw, layoutType);
739
+ }
740
+ if (slideData) {
741
+ const html = buildContentSlideHtml(design, slide.title, layoutType, slideData, slideIndex + 1, slideIndex);
742
+ return { html, isCodeTemplate: true };
743
+ }
780
744
  }
781
- if (plan.slides.length > 15) {
782
- logger.warn(`PPT plan has ${plan.slides.length} slides, capping to 15`);
783
- const firstSlide = plan.slides[0];
784
- const lastSlide = plan.slides[plan.slides.length - 1];
785
- const contentSlides = plan.slides.slice(1, -1).slice(0, 13);
786
- plan.slides = [firstSlide, ...contentSlides, lastSlide];
787
- if (phaseLogger)
788
- phaseLogger('powerpoint-create', 'planning', `Capped to ${plan.slides.length} slides`);
745
+ catch (e) {
746
+ logger.warn(`Slide ${slideIndex + 1}: Code template fallback failed: ${e}`);
789
747
  }
790
- if (phaseLogger)
791
- phaseLogger('powerpoint-create', 'execution', 'Starting HTML rendering pipeline...');
792
- const { writePath: tempWritePath, winPath: tempWinPath } = getTempDir();
793
- ensureTempDir(tempWritePath);
794
- let savePath = explicitSavePath;
795
- if (!savePath) {
796
- const fullPathMatch = instruction.match(/([A-Za-z]:\\[^\s,]+\.pptx|\/[^\s,]+\.pptx)/i);
797
- if (fullPathMatch) {
798
- savePath = fullPathMatch[1];
748
+ return null;
749
+ }
750
+ async function generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstDate, language, phaseLogger) {
751
+ const results = new Map();
752
+ for (let i = 0; i < plan.slides.length; i++) {
753
+ const slide = plan.slides[i];
754
+ if (slide.type === 'title') {
755
+ results.set(i, {
756
+ index: i,
757
+ html: buildTitleSlideHtml(plan.design, companyName, titleSubtitle, kstDate, i + 1),
758
+ isCodeTemplate: true,
759
+ });
799
760
  }
800
- else {
801
- const nameMatch = instruction.match(/([\w][\w\-_.]*\.pptx)/i);
802
- if (nameMatch) {
803
- savePath = `C:\\temp\\${nameMatch[1]}`;
761
+ else if (slide.type === 'closing') {
762
+ const closingTagline = (slide.content_direction || '').replace(/감사합니다|thank\s*you/gi, '').trim() || undefined;
763
+ results.set(i, {
764
+ index: i,
765
+ html: buildClosingSlideHtml(plan.design, companyName, i + 1, language, closingTagline),
766
+ isCodeTemplate: true,
767
+ });
768
+ }
769
+ else if (isOverviewSlide(slide.title, i)) {
770
+ const overviewItems = parseOverviewItems(slide.content_direction || '');
771
+ if (overviewItems.length >= 2) {
772
+ const firstLine = (slide.content_direction || '').split('\n')[0] || '';
773
+ const overviewSubtitle = /^\d/.test(firstLine.trim()) ? '' : firstLine.trim();
774
+ results.set(i, {
775
+ index: i,
776
+ html: buildOverviewSlideHtml(plan.design, slide.title, overviewSubtitle, overviewItems, i + 1),
777
+ isCodeTemplate: true,
778
+ });
804
779
  }
805
780
  }
806
781
  }
807
- const kstNow = new Date(Date.now() + 9 * 60 * 60 * 1000);
808
- const kstDate = `${kstNow.getUTCFullYear()}년 ${kstNow.getUTCMonth() + 1}월`;
809
- const titleSlidePlan = plan.slides.find(s => s.type === 'title');
810
- const rawTitleText = titleSlidePlan?.title || '';
811
- const titleSeps = [' - ', '', ' ', ': ', ' | '];
812
- let companyName = rawTitleText;
813
- let titleSubtitle = '';
814
- for (const sep of titleSeps) {
815
- const idx = rawTitleText.indexOf(sep);
816
- if (idx > 0) {
817
- companyName = rawTitleText.slice(0, idx).trim();
818
- titleSubtitle = rawTitleText.slice(idx + sep.length).trim();
819
- break;
782
+ const contentIndices = plan.slides
783
+ .map((s, i) => ({ slide: s, index: i }))
784
+ .filter(({ index }) => !results.has(index));
785
+ if (phaseLogger)
786
+ phaseLogger('powerpoint-create', 'html-generation', `Generating ${contentIndices.length} content slides in parallel (batch size ${MAX_CONCURRENT})...`);
787
+ for (let batch = 0; batch < contentIndices.length; batch += MAX_CONCURRENT) {
788
+ const chunk = contentIndices.slice(batch, batch + MAX_CONCURRENT);
789
+ const promises = chunk.map(({ slide, index }) => generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language)
790
+ .then(result => ({ index, result }))
791
+ .catch(err => {
792
+ logger.warn(`Slide ${index + 1}: Generation error: ${err}`);
793
+ return { index, result: null };
794
+ }));
795
+ const chunkResults = await Promise.all(promises);
796
+ for (const { index, result } of chunkResults) {
797
+ if (result) {
798
+ results.set(index, { index, html: result.html, isCodeTemplate: result.isCodeTemplate });
799
+ }
820
800
  }
801
+ const done = Math.min(batch + MAX_CONCURRENT, contentIndices.length);
802
+ if (phaseLogger)
803
+ phaseLogger('powerpoint-create', 'html-generation', `Generated ${done}/${contentIndices.length} content slides`);
821
804
  }
822
- if (!titleSubtitle && titleSlidePlan) {
823
- titleSubtitle = ((titleSlidePlan.content_direction || '').split('\n')[0] || '').trim().slice(0, 120);
805
+ return results;
806
+ }
807
+ async function validateAndRegenerate(llmClient, htmlResults, plan, language, phaseLogger) {
808
+ const failedIndices = [];
809
+ for (const [index, result] of htmlResults) {
810
+ const slide = plan.slides[index];
811
+ if (slide.type === 'title' || slide.type === 'closing')
812
+ continue;
813
+ const layoutType = extractLayoutHint(slide.content_direction || '');
814
+ const validation = validateSlideHtml(result.html, layoutType);
815
+ if (!validation.pass) {
816
+ logger.info(`Slide ${index + 1}: Post-validation failed: ${validation.feedback}`);
817
+ failedIndices.push(index);
818
+ }
824
819
  }
825
- if (/로고|슬로건|연락처|contact|logo|placeholder/i.test(titleSubtitle)) {
826
- titleSubtitle = '';
820
+ if (failedIndices.length === 0) {
821
+ if (phaseLogger)
822
+ phaseLogger('powerpoint-create', 'validation', 'All slides passed validation');
823
+ return htmlResults;
827
824
  }
828
- const createResult = await powerpointClient.powerpointCreate();
829
- totalToolCalls++;
830
- if (toolCallLogger)
831
- toolCallLogger('powerpoint-create', 'powerpoint_create', {}, createResult.success ? 'Created' : createResult['error'] || '', createResult.success, 0, totalToolCalls);
832
- if (!createResult.success) {
833
- return { success: false, error: `Failed to create presentation: ${createResult['error']}` };
825
+ if (phaseLogger)
826
+ phaseLogger('powerpoint-create', 'validation', `${failedIndices.length} slides failed validation, regenerating...`);
827
+ for (const index of failedIndices) {
828
+ const slide = plan.slides[index];
829
+ const original = htmlResults.get(index);
830
+ const result = await generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language);
831
+ if (result) {
832
+ const regenHasPlaceholder = hasPlaceholderText(result.html);
833
+ const origHasPlaceholder = hasPlaceholderText(original.html);
834
+ if (!regenHasPlaceholder || origHasPlaceholder) {
835
+ htmlResults.set(index, { index, html: result.html, isCodeTemplate: result.isCodeTemplate });
836
+ }
837
+ }
834
838
  }
839
+ return htmlResults;
840
+ }
841
+ async function assemblePresentation(htmlResults, plan, timestamp, savePath, phaseLogger, toolCallLogger) {
842
+ const { writePath: tempWritePath, winPath: tempWinPath } = getTempDir();
843
+ ensureTempDir(tempWritePath);
835
844
  const builtSlides = [];
836
845
  let failCount = 0;
846
+ let totalToolCalls = 0;
837
847
  const tempFiles = [];
838
- for (let i = 0; i < plan.slides.length; i++) {
839
- const slidePlan = plan.slides[i];
840
- const slideNum = i + 1;
848
+ const sortedEntries = [...htmlResults.entries()].sort((a, b) => a[0] - b[0]);
849
+ for (const [index, result] of sortedEntries) {
850
+ const slidePlan = plan.slides[index];
851
+ const slideNum = builtSlides.length + 1;
841
852
  if (failCount >= 3) {
842
853
  logger.warn('Too many slide failures, stopping');
843
854
  break;
844
855
  }
845
856
  if (phaseLogger)
846
- phaseLogger('powerpoint-create', 'execution', `Rendering slide ${slideNum}/${plan.slides.length}: ${slidePlan.title}`);
847
- let html = null;
848
- let htmlPrompt = null;
849
- if (slidePlan.type === 'title') {
850
- html = buildTitleSlideHtml(plan.design, companyName, titleSubtitle, kstDate, slideNum);
851
- }
852
- else if (slidePlan.type === 'closing') {
853
- html = buildClosingSlideHtml(plan.design, companyName, slideNum, language);
854
- }
855
- else if (isOverviewSlide(slidePlan.title, i)) {
856
- const overviewItems = parseOverviewItems(slidePlan.content_direction || '');
857
- if (overviewItems.length >= 2) {
858
- const firstLine = (slidePlan.content_direction || '').split('\n')[0] || '';
859
- const overviewSubtitle = /^\d/.test(firstLine.trim()) ? '' : firstLine.trim();
860
- html = buildOverviewSlideHtml(plan.design, slidePlan.title, overviewSubtitle, overviewItems, slideNum);
861
- logger.info(`Slide ${slideNum}: Using code-generated overview template (${overviewItems.length} items)`);
862
- }
863
- }
864
- if (!html && slidePlan.type !== 'title' && slidePlan.type !== 'closing') {
865
- htmlPrompt = buildSlideHtmlPrompt(slidePlan.title, slidePlan.content_direction || '', plan.design, i, plan.slides.length, language);
866
- try {
867
- const htmlRes = await llmClient.chatCompletion({
868
- messages: [
869
- { role: 'system', content: htmlPrompt },
870
- { role: 'user', content: 'Generate the HTML slide now.' },
871
- ],
872
- temperature: 0.3,
873
- max_tokens: 8000,
874
- });
875
- const htmlMsg = htmlRes.choices[0]?.message;
876
- const rawHtml = htmlMsg ? extractContent(htmlMsg) : '';
877
- html = extractHtml(rawHtml);
878
- if (!html && rawHtml.length > 100) {
879
- logger.warn(`Slide ${slideNum}: HTML extraction failed, retrying`);
880
- const retryRes = await llmClient.chatCompletion({
881
- messages: [
882
- { role: 'system', content: htmlPrompt },
883
- { role: 'user', content: 'Generate the complete HTML document. Start with <!DOCTYPE html> and end with </html>. No markdown fences.' },
884
- ],
885
- temperature: 0.2,
886
- max_tokens: 4000,
887
- });
888
- const retryMsg = retryRes.choices[0]?.message;
889
- const retryRaw = retryMsg ? extractContent(retryMsg) : '';
890
- html = extractHtml(retryRaw);
891
- }
892
- }
893
- catch (e) {
894
- logger.warn(`Slide ${slideNum}: LLM call failed: ${e}`);
895
- }
896
- }
897
- if (!html) {
898
- logger.warn(`Slide ${slideNum}: Failed to generate HTML, skipping`);
899
- failCount++;
900
- continue;
901
- }
902
- if (htmlPrompt) {
903
- const MAX_REGEN_ATTEMPTS = 5;
904
- for (let attempt = 0; attempt < MAX_REGEN_ATTEMPTS; attempt++) {
905
- const measureFileName = `hanseol_measure_${slideNum}_${timestamp}_${attempt}.html`;
906
- const measureWritePath = path.join(tempWritePath, measureFileName);
907
- const measureWinPath = `${tempWinPath}\\${measureFileName}`;
908
- try {
909
- fs.writeFileSync(measureWritePath, injectMeasureCss(html), 'utf-8');
910
- const contentHeight = await powerpointClient.measureHtmlHeight(measureWinPath);
911
- try {
912
- fs.unlinkSync(measureWritePath);
913
- }
914
- catch { }
915
- if (contentHeight <= 1050) {
916
- if (attempt > 0)
917
- logger.info(`Slide ${slideNum}: Fits after ${attempt} regeneration(s) (${contentHeight}px)`);
918
- break;
919
- }
920
- logger.warn(`Slide ${slideNum}: Content overflows (${contentHeight}px > 1050), attempt ${attempt + 1}/${MAX_REGEN_ATTEMPTS}`);
921
- if (phaseLogger)
922
- phaseLogger('powerpoint-create', 'execution', `Slide ${slideNum}: overflow (${contentHeight}px), regen ${attempt + 1}/${MAX_REGEN_ATTEMPTS}...`);
923
- try {
924
- const regenRes = await llmClient.chatCompletion({
925
- messages: [
926
- { role: 'system', content: htmlPrompt },
927
- { role: 'user', content: `Generate the HTML slide now. CRITICAL: Your previous version was ${contentHeight}px tall but the slide is only 1080px. Content was CLIPPED at the bottom.\n\nYou MUST:\n- Use FEWER items (max 3 cards, max 3 rows, max 3 metrics)\n- SHORTER text per item (1-2 lines max)\n- SIMPLER layout with generous padding\n- body padding: 60px 80px minimum\n- Do NOT exceed 3 content blocks total\n\nThis is attempt ${attempt + 1}. ${attempt >= 2 ? 'DRASTICALLY simplify — use only 2 main items with large text.' : ''}` },
928
- ],
929
- temperature: 0.2,
930
- max_tokens: 8000,
931
- });
932
- const regenMsg = regenRes.choices[0]?.message;
933
- const regenRaw = regenMsg ? extractContent(regenMsg) : '';
934
- const regenHtml = extractHtml(regenRaw);
935
- if (regenHtml) {
936
- html = regenHtml;
937
- logger.info(`Slide ${slideNum}: Regenerated HTML (attempt ${attempt + 1})`);
938
- }
939
- else {
940
- break;
941
- }
942
- }
943
- catch (e) {
944
- logger.warn(`Slide ${slideNum}: Overflow regeneration attempt ${attempt + 1} failed: ${e}`);
945
- break;
946
- }
947
- }
948
- catch {
949
- break;
950
- }
951
- }
952
- }
857
+ phaseLogger('powerpoint-create', 'assembly', `Rendering slide ${slideNum}: ${slidePlan.title}`);
953
858
  const htmlFileName = `hanseol_slide_${slideNum}_${timestamp}.html`;
954
859
  const pngFileName = `hanseol_slide_${slideNum}_${timestamp}.png`;
955
860
  const htmlWritePath = path.join(tempWritePath, htmlFileName);
@@ -957,7 +862,10 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
957
862
  const htmlWinPath = `${tempWinPath}\\${htmlFileName}`;
958
863
  const pngWinPath = `${tempWinPath}\\${pngFileName}`;
959
864
  try {
960
- const processed = injectTitleContrastFix(ensureSafeBodyPadding(removeAbsolutePositioning(enforceMinFontSize(injectViewportCss(html, plan.design.background_color)))), plan.design.text_color);
865
+ const viewportHtml = result.isCodeTemplate
866
+ ? injectEdgeSizing(result.html, plan.design.background_color)
867
+ : injectViewportCss(result.html, plan.design.background_color);
868
+ const processed = injectTitleContrastFix(viewportHtml, plan.design.text_color);
961
869
  fs.writeFileSync(htmlWritePath, processed, 'utf-8');
962
870
  tempFiles.push(htmlWritePath);
963
871
  }
@@ -990,6 +898,10 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
990
898
  renderSuccess = false;
991
899
  }
992
900
  }
901
+ try {
902
+ fs.unlinkSync(htmlWritePath);
903
+ }
904
+ catch { }
993
905
  if (!renderSuccess) {
994
906
  logger.warn(`Slide ${slideNum}: Screenshot rendering failed, skipping`);
995
907
  failCount++;
@@ -1023,7 +935,7 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1023
935
  if (bgResult.success) {
1024
936
  builtSlides.push(`Slide ${slideNum}: ${slidePlan.title} (${slidePlan.type})`);
1025
937
  try {
1026
- await powerpointClient.powerpointAddNote(slideNum, html);
938
+ await powerpointClient.powerpointAddNote(slideNum, result.html);
1027
939
  }
1028
940
  catch { }
1029
941
  }
@@ -1037,7 +949,6 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1037
949
  const slideCountResult = await powerpointClient.powerpointGetSlideCount();
1038
950
  const totalSlidesInPpt = slideCountResult['slide_count'] || 0;
1039
951
  if (totalSlidesInPpt > builtSlides.length) {
1040
- logger.warn(`PPT has ${totalSlidesInPpt} slides but only ${builtSlides.length} were rendered — deleting ${totalSlidesInPpt - builtSlides.length} trailing blanks`);
1041
952
  for (let d = totalSlidesInPpt; d > builtSlides.length; d--) {
1042
953
  await powerpointClient.powerpointDeleteSlide(d);
1043
954
  totalToolCalls++;
@@ -1049,6 +960,13 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1049
960
  }
1050
961
  }
1051
962
  if (builtSlides.length > 0) {
963
+ if (savePath) {
964
+ const wslSavePath = savePath.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_m, d) => `/mnt/${d.toLowerCase()}`);
965
+ try {
966
+ fs.unlinkSync(wslSavePath);
967
+ }
968
+ catch { }
969
+ }
1052
970
  let saveResult = await powerpointClient.powerpointSave(savePath);
1053
971
  totalToolCalls++;
1054
972
  if (toolCallLogger)
@@ -1057,8 +975,6 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1057
975
  const fallbackPath = 'C:\\temp\\presentation.pptx';
1058
976
  saveResult = await powerpointClient.powerpointSave(fallbackPath);
1059
977
  totalToolCalls++;
1060
- if (toolCallLogger)
1061
- toolCallLogger('powerpoint-create', 'powerpoint_save', { path: fallbackPath }, saveResult.success ? (saveResult['path'] || 'OK') : (saveResult.error || 'Failed'), saveResult.success, 0, totalToolCalls);
1062
978
  }
1063
979
  }
1064
980
  for (const tempFile of tempFiles) {
@@ -1067,9 +983,117 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1067
983
  }
1068
984
  catch { }
1069
985
  }
986
+ return { builtSlides, totalToolCalls };
987
+ }
988
+ async function runStructured(llmClient, instruction, explicitSavePath) {
989
+ const startTime = Date.now();
990
+ const phaseLogger = getSubAgentPhaseLogger();
991
+ const toolCallLogger = getSubAgentToolCallLogger();
992
+ const timestamp = Date.now();
993
+ logger.enter('PPT-Create.runStructured.v2');
994
+ const hasKorean = /[\uac00-\ud7af\u1100-\u11ff]/.test(instruction);
995
+ const language = hasKorean ? 'ko' : 'en';
996
+ if (phaseLogger)
997
+ phaseLogger('powerpoint-create', 'init', 'Starting Design phase + opening PowerPoint...');
998
+ const [plan, createResult] = await Promise.all([
999
+ runDesignPhase(llmClient, instruction, phaseLogger),
1000
+ powerpointClient.powerpointCreate(),
1001
+ ]);
1002
+ if (!createResult.success) {
1003
+ return { success: false, error: `Failed to create presentation: ${createResult['error']}` };
1004
+ }
1005
+ if (!plan) {
1006
+ logger.error('PPT planning failed after retries — cannot create presentation');
1007
+ return { success: false, error: 'Failed to generate presentation plan. Please try again.' };
1008
+ }
1009
+ if (plan.slides.length > 20) {
1010
+ const firstSlide = plan.slides[0];
1011
+ const lastSlide = plan.slides[plan.slides.length - 1];
1012
+ const contentSlides = plan.slides.slice(1, -1).slice(0, 18);
1013
+ plan.slides = [firstSlide, ...contentSlides, lastSlide];
1014
+ }
1015
+ const userYearMatch = instruction.match(/(\d{4})년/);
1016
+ if (userYearMatch) {
1017
+ const userYear = userYearMatch[1];
1018
+ for (const slide of plan.slides) {
1019
+ if (slide.type === 'content' && slide.content_direction) {
1020
+ if (!slide.content_direction.includes(`${userYear}년`)) {
1021
+ slide.content_direction += ` (Note: This report covers ${userYear}년 data.)`;
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ let savePath = explicitSavePath;
1027
+ if (!savePath) {
1028
+ const fullPathMatch = instruction.match(/([A-Za-z]:\\[^\s,]+\.pptx|\/[^\s,]+\.pptx)/i);
1029
+ if (fullPathMatch) {
1030
+ savePath = fullPathMatch[1];
1031
+ }
1032
+ else {
1033
+ const nameMatch = instruction.match(/([\w][\w\-_.]*\.pptx)/i);
1034
+ if (nameMatch) {
1035
+ savePath = `C:\\temp\\${nameMatch[1]}`;
1036
+ }
1037
+ }
1038
+ }
1039
+ const titleSlidePlanForDate = plan.slides.find(s => s.type === 'title');
1040
+ const dateSearchTexts = [instruction, titleSlidePlanForDate?.title || '', titleSlidePlanForDate?.content_direction || ''];
1041
+ let kstDate = '';
1042
+ for (const text of dateSearchTexts) {
1043
+ const dateMatch = text.match(/(\d{4})년\s*(\d{1,2})\s*(월|분기)/);
1044
+ if (dateMatch) {
1045
+ kstDate = `${dateMatch[1]}년 ${dateMatch[2]}${dateMatch[3]}`;
1046
+ break;
1047
+ }
1048
+ }
1049
+ if (!kstDate) {
1050
+ const kstNow = new Date(Date.now() + 9 * 60 * 60 * 1000);
1051
+ kstDate = `${kstNow.getUTCFullYear()}년 ${kstNow.getUTCMonth() + 1}월`;
1052
+ }
1053
+ const titleSlidePlan = plan.slides.find(s => s.type === 'title');
1054
+ const rawTitleText = titleSlidePlan?.title || '';
1055
+ const titleSeps = [' - ', ' – ', ' — ', ': ', ' | '];
1056
+ let companyName = rawTitleText;
1057
+ let titleSubtitle = '';
1058
+ for (const sep of titleSeps) {
1059
+ const idx = rawTitleText.indexOf(sep);
1060
+ if (idx > 0) {
1061
+ companyName = rawTitleText.slice(0, idx).trim();
1062
+ titleSubtitle = rawTitleText.slice(idx + sep.length).trim();
1063
+ break;
1064
+ }
1065
+ }
1066
+ if (!titleSubtitle && titleSlidePlan) {
1067
+ titleSubtitle = ((titleSlidePlan.content_direction || '').split('\n')[0] || '').trim().slice(0, 120);
1068
+ }
1069
+ if (/로고|슬로건|연락처|contact|logo|placeholder/i.test(titleSubtitle)) {
1070
+ titleSubtitle = '';
1071
+ }
1072
+ const companyMatch = instruction.match(/회사명\s*[::]\s*([^\s,,、]+)/);
1073
+ if (companyMatch && companyMatch[1]) {
1074
+ const companyName_ = companyMatch[1];
1075
+ if (titleSlidePlan && titleSlidePlan.title.trim() !== companyName_) {
1076
+ const originalTitle = titleSlidePlan.title;
1077
+ titleSlidePlan.title = companyName_;
1078
+ companyName = companyName_;
1079
+ if (!titleSlidePlan.content_direction?.includes(originalTitle)) {
1080
+ const stripped = originalTitle.replace(companyName_, '').replace(/^\s*[-–—:|\s]+/, '').trim();
1081
+ titleSubtitle = stripped || originalTitle;
1082
+ titleSlidePlan.content_direction = titleSubtitle + (titleSlidePlan.content_direction ? '\n' + titleSlidePlan.content_direction : '');
1083
+ }
1084
+ }
1085
+ }
1086
+ if (phaseLogger)
1087
+ phaseLogger('powerpoint-create', 'html-generation', 'Starting parallel HTML generation...');
1088
+ const htmlResults = await generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstDate, language, phaseLogger);
1089
+ const validatedResults = await validateAndRegenerate(llmClient, htmlResults, plan, language, phaseLogger);
1090
+ if (phaseLogger)
1091
+ phaseLogger('powerpoint-create', 'assembly', `Assembling ${validatedResults.size} slides into PowerPoint...`);
1092
+ const { builtSlides, totalToolCalls } = await assemblePresentation(validatedResults, plan, timestamp, savePath, phaseLogger, toolCallLogger);
1070
1093
  const duration = Date.now() - startTime;
1071
- const summary = `Presentation created with ${builtSlides.length} slides (HTML rendering, ${plan.design.mood}):\n${builtSlides.join('\n')}`;
1072
- logger.exit('PPT-Create.runStructured', { slideCount: builtSlides.length, totalToolCalls, duration });
1094
+ const slideList = builtSlides.join('\n');
1095
+ const summary = `Presentation COMPLETE — ${builtSlides.length} slides created and saved successfully.\nAll requested topics are covered across these slides. Do NOT add more slides or call powerpoint_modify_agent.\n\nSlides:\n${slideList}`;
1096
+ logger.exit('PPT-Create.runStructured.v2', { slideCount: builtSlides.length, totalToolCalls, duration });
1073
1097
  return {
1074
1098
  success: builtSlides.length > 0,
1075
1099
  result: summary,