hanseol-dev 5.0.3-dev.2 → 5.0.3-dev.21

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.
@@ -1 +1 @@
1
- {"version":3,"file":"powerpoint-create-agent.d.ts","sourceRoot":"","sources":["../../../src/agents/office/powerpoint-create-agent.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,YAAY,EAAc,MAAM,sBAAsB,CAAC;AAkzChE,wBAAgB,iCAAiC,IAAI,YAAY,CAkChE"}
1
+ {"version":3,"file":"powerpoint-create-agent.d.ts","sourceRoot":"","sources":["../../../src/agents/office/powerpoint-create-agent.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,YAAY,EAAc,MAAM,sBAAsB,CAAC;AAwgDhE,wBAAgB,iCAAiC,IAAI,YAAY,CAkChE"}
@@ -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_DESIGN_PROMPT, buildDirectHtmlPrompt, validateSlideHtml, extractLayoutHint, buildContentFillJsonPrompt, parseContentFillJson, buildContentSlideHtml, } from './powerpoint-create-prompts.js';
7
+ import { PPT_DESIGN_PROMPT, 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,7 +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
+ const MAX_CONCURRENT = 5;
21
21
  function extractContent(msg) {
22
22
  const content = msg['content'];
23
23
  if (content && content.trim())
@@ -228,18 +228,6 @@ function parseJsonPlan(raw) {
228
228
  return null;
229
229
  }
230
230
  }
231
- function extractHtml(raw) {
232
- const trimmed = raw.trim();
233
- if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html'))
234
- return trimmed;
235
- const fenceMatch = trimmed.match(/```(?:html)?\s*\n([\s\S]*?)\n```/);
236
- if (fenceMatch?.[1])
237
- return fenceMatch[1].trim();
238
- const docMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i);
239
- if (docMatch?.[1])
240
- return docMatch[1].trim();
241
- return null;
242
- }
243
231
  function injectEdgeSizing(html, backgroundColor) {
244
232
  let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
245
233
  const bgColor = backgroundColor || '#000000';
@@ -258,24 +246,6 @@ function injectEdgeSizing(html, backgroundColor) {
258
246
  }
259
247
  return result;
260
248
  }
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;
276
- }
277
- return result;
278
- }
279
249
  function injectTitleContrastFix(html, designTextColor) {
280
250
  const safeColor = designTextColor.replace(/'/g, "\\'");
281
251
  const script = `<script>(function(){` +
@@ -589,6 +559,74 @@ body {
589
559
  </body>
590
560
  </html>`;
591
561
  }
562
+ function buildFallbackSlideHtml(design, title, contentDirection, slideNum) {
563
+ const items = [];
564
+ const parts = contentDirection.split(/\(\d+\)\s*|•\s*|\n-\s*|\n\d+[.)]\s*|Step\s*\d+[.:]\s*/i);
565
+ for (const part of parts) {
566
+ const cleaned = part.replace(/Layout:.*$/i, '').trim();
567
+ if (cleaned.length > 5) {
568
+ const sentence = cleaned.split(/[.。!]\s/)[0] || cleaned;
569
+ items.push(sentence.slice(0, 80));
570
+ }
571
+ }
572
+ if (items.length < 3) {
573
+ const commaParts = contentDirection.split(/[,;,;]\s*/);
574
+ for (const part of commaParts) {
575
+ const cleaned = part.replace(/Layout:.*$/i, '').trim();
576
+ if (cleaned.length > 8 && !items.includes(cleaned.slice(0, 80))) {
577
+ items.push(cleaned.slice(0, 80));
578
+ }
579
+ }
580
+ }
581
+ if (items.length < 3) {
582
+ const sentences = contentDirection.split(/[.。!?]\s+/);
583
+ for (const s of sentences) {
584
+ const cleaned = s.replace(/Layout:.*$/i, '').trim();
585
+ if (cleaned.length > 10 && !items.some(i => i.startsWith(cleaned.slice(0, 20)))) {
586
+ items.push(cleaned.slice(0, 80));
587
+ }
588
+ }
589
+ }
590
+ const bulletItems = items.slice(0, 6);
591
+ const useGrid = bulletItems.length >= 4;
592
+ const gridCols = useGrid ? 'grid-template-columns:1fr 1fr' : 'grid-template-columns:1fr';
593
+ const pointsHtml = bulletItems.map((item, i) => `<div class="point"><div class="point-num">${i + 1}</div><div class="point-text">${escapeHtml(item)}</div></div>`).join('\n ');
594
+ return `<!DOCTYPE html>
595
+ <html lang="ko">
596
+ <head>
597
+ <meta charset="UTF-8">
598
+ <style>
599
+ * { margin: 0; padding: 0; box-sizing: border-box; }
600
+ html, body { width: 1920px; height: 1080px; overflow: hidden; }
601
+ body {
602
+ background: ${design.background_color};
603
+ font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
604
+ padding: 80px 100px;
605
+ display: flex;
606
+ flex-direction: column;
607
+ }
608
+ .header { margin-bottom: 48px; }
609
+ .slide-num { font-size: 14px; color: ${design.accent_color}; font-weight: 600; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 12px; }
610
+ h1 { font-size: 52px; font-weight: 700; color: ${design.text_color}; font-family: "${design.font_title}", "Segoe UI", sans-serif; line-height: 1.2; }
611
+ .accent-bar { width: 80px; height: 4px; background: ${design.accent_color}; margin-top: 20px; border-radius: 2px; }
612
+ .content { flex: 1; display: grid; ${gridCols}; gap: 24px; align-content: center; }
613
+ .point { display: flex; align-items: flex-start; gap: 20px; padding: 28px 32px; background: #fff; border-radius: 16px; box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
614
+ .point-num { width: 48px; height: 48px; border-radius: 50%; background: ${design.accent_color}; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 700; flex-shrink: 0; }
615
+ .point-text { font-size: 26px; line-height: 1.5; color: ${design.text_color}; flex: 1; }
616
+ </style>
617
+ </head>
618
+ <body>
619
+ <div class="header">
620
+ <div class="slide-num">SLIDE ${slideNum}</div>
621
+ <h1>${escapeHtml(title)}</h1>
622
+ <div class="accent-bar"></div>
623
+ </div>
624
+ <div class="content">
625
+ ${pointsHtml}
626
+ </div>
627
+ </body>
628
+ </html>`;
629
+ }
592
630
  function getTempDir() {
593
631
  const platform = getPlatform();
594
632
  if (platform === 'wsl') {
@@ -683,34 +721,64 @@ async function runDesignPhase(llmClient, instruction, phaseLogger) {
683
721
  }
684
722
  return plan;
685
723
  }
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 };
724
+ const LAYOUT_ALTERNATIVES = {
725
+ cards: ['progress_bars', 'hero_stat', 'donut_chart', 'two_col_split', 'process_flow', 'timeline'],
726
+ };
727
+ const CARDS_KEEP_KEYWORDS = /team|팀|case|사례|success|성과|partner|파트너|고객|customer|member|멤버/i;
728
+ function rebalanceLayouts(plan) {
729
+ const contentSlides = plan.slides.filter(s => s.type === 'content');
730
+ const counts = new Map();
731
+ const slidesByLayout = new Map();
732
+ for (const slide of contentSlides) {
733
+ const layout = extractLayoutHint(slide.content_direction || '');
734
+ counts.set(layout, (counts.get(layout) || 0) + 1);
735
+ if (!slidesByLayout.has(layout))
736
+ slidesByLayout.set(layout, []);
737
+ slidesByLayout.get(layout).push(slide);
738
+ }
739
+ for (const [layout, count] of counts) {
740
+ if (count <= 2)
741
+ continue;
742
+ const slides = slidesByLayout.get(layout);
743
+ const keepSlides = slides.filter(s => CARDS_KEEP_KEYWORDS.test(s.title));
744
+ const reassignCandidates = slides.filter(s => !CARDS_KEEP_KEYWORDS.test(s.title));
745
+ const keepCount = Math.max(2, keepSlides.length);
746
+ const excess = reassignCandidates.slice(Math.max(0, keepCount - keepSlides.length));
747
+ if (excess.length === 0)
748
+ continue;
749
+ const alternatives = LAYOUT_ALTERNATIVES[layout] || ['progress_bars', 'hero_stat', 'donut_chart', 'two_col_split'];
750
+ const usedAlts = new Set();
751
+ for (const slide of excess) {
752
+ let chosen = null;
753
+ for (const alt of alternatives) {
754
+ const altCount = (counts.get(alt) || 0) + (usedAlts.has(alt) ? 1 : 0);
755
+ if (altCount < 2) {
756
+ chosen = alt;
757
+ usedAlts.add(alt);
758
+ break;
759
+ }
706
760
  }
707
- logger.info(`Slide ${slideIndex + 1}: Direct HTML failed validation: ${validation.feedback}`);
761
+ if (!chosen)
762
+ chosen = alternatives[0];
763
+ const cd = slide.content_direction || '';
764
+ const hasLayoutHint = /Layout:\s*.+$/im.test(cd);
765
+ const layoutName = chosen.replace(/_/g, ' ');
766
+ if (hasLayoutHint) {
767
+ slide.content_direction = cd.replace(/Layout:\s*.+$/im, `Layout: ${layoutName}`);
768
+ }
769
+ else {
770
+ slide.content_direction = cd + `\nLayout: ${layoutName}`;
771
+ }
772
+ counts.set(chosen, (counts.get(chosen) || 0) + 1);
773
+ counts.set(layout, counts.get(layout) - 1);
774
+ logger.info(`Rebalance: "${slide.title}" layout changed from ${layout} → ${chosen}`);
708
775
  }
709
776
  }
710
- catch (e) {
711
- logger.warn(`Slide ${slideIndex + 1}: Direct HTML LLM call failed: ${e}`);
712
- }
713
- logger.info(`Slide ${slideIndex + 1}: Falling back to code template "${layoutType}"`);
777
+ }
778
+ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, _totalSlides, language) {
779
+ const cleanedDirection = (slide.content_direction || '').replace(/\s*Layout\s*:\s*[^\n]*/gi, '').trim();
780
+ const layoutType = extractLayoutHint(slide.content_direction || '');
781
+ logger.info(`Slide ${slideIndex + 1}: Generating code template "${layoutType}"`);
714
782
  const jsonPrompt = buildContentFillJsonPrompt(slide.title, cleanedDirection, layoutType, language);
715
783
  try {
716
784
  const jsonRes = await llmClient.chatCompletion({
@@ -725,6 +793,7 @@ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, tot
725
793
  const jsonRaw = jsonMsg ? extractContent(jsonMsg) : '';
726
794
  let slideData = parseContentFillJson(jsonRaw, layoutType);
727
795
  if (!slideData) {
796
+ logger.warn(`Slide ${slideIndex + 1}: JSON fill parse failed (attempt 1). Layout: ${layoutType}. Raw length: ${jsonRaw.length}. First 200 chars: ${jsonRaw.slice(0, 200)}`);
728
797
  const retryRes = await llmClient.chatCompletion({
729
798
  messages: [
730
799
  { role: 'system', content: jsonPrompt },
@@ -736,6 +805,9 @@ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, tot
736
805
  const retryMsg = retryRes.choices[0]?.message;
737
806
  const retryRaw = retryMsg ? extractContent(retryMsg) : '';
738
807
  slideData = parseContentFillJson(retryRaw, layoutType);
808
+ if (!slideData) {
809
+ logger.warn(`Slide ${slideIndex + 1}: JSON fill parse failed (attempt 2). Raw length: ${retryRaw.length}. First 200 chars: ${retryRaw.slice(0, 200)}`);
810
+ }
739
811
  }
740
812
  if (slideData) {
741
813
  const html = buildContentSlideHtml(design, slide.title, layoutType, slideData, slideIndex + 1, slideIndex);
@@ -797,6 +869,12 @@ async function generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstD
797
869
  if (result) {
798
870
  results.set(index, { index, html: result.html, isCodeTemplate: result.isCodeTemplate });
799
871
  }
872
+ else {
873
+ const slide = plan.slides[index];
874
+ const fallbackHtml = buildFallbackSlideHtml(plan.design, slide.title, slide.content_direction || '', index + 1);
875
+ results.set(index, { index, html: fallbackHtml, isCodeTemplate: true });
876
+ logger.warn(`Slide ${index + 1}: Using fallback HTML for "${slide.title}"`);
877
+ }
800
878
  }
801
879
  const done = Math.min(batch + MAX_CONCURRENT, contentIndices.length);
802
880
  if (phaseLogger)
@@ -823,38 +901,55 @@ async function validateAndRegenerate(llmClient, htmlResults, plan, language, pha
823
901
  return htmlResults;
824
902
  }
825
903
  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 });
904
+ phaseLogger('powerpoint-create', 'validation', `${failedIndices.length} slides failed validation, regenerating in parallel...`);
905
+ for (let batch = 0; batch < failedIndices.length; batch += MAX_CONCURRENT) {
906
+ const chunk = failedIndices.slice(batch, batch + MAX_CONCURRENT);
907
+ const promises = chunk.map(async (index) => {
908
+ const slide = plan.slides[index];
909
+ const original = htmlResults.get(index);
910
+ try {
911
+ const result = await generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language);
912
+ if (result) {
913
+ const regenHasPlaceholder = hasPlaceholderText(result.html);
914
+ const origHasPlaceholder = hasPlaceholderText(original.html);
915
+ if (!regenHasPlaceholder || origHasPlaceholder) {
916
+ return { index, result: { index, html: result.html, isCodeTemplate: result.isCodeTemplate } };
917
+ }
918
+ }
919
+ }
920
+ catch (e) {
921
+ logger.warn(`Slide ${index + 1}: Regen error: ${e}`);
922
+ }
923
+ return { index, result: null };
924
+ });
925
+ const chunkResults = await Promise.all(promises);
926
+ for (const { index, result } of chunkResults) {
927
+ if (result) {
928
+ htmlResults.set(index, result);
836
929
  }
837
930
  }
838
931
  }
839
932
  return htmlResults;
840
933
  }
841
- async function assemblePresentation(htmlResults, plan, timestamp, savePath, phaseLogger, toolCallLogger) {
934
+ const MAX_SCREENSHOT_CONCURRENT = 4;
935
+ async function assemblePresentation(htmlResults, plan, timestamp, savePath, companyName, language, phaseLogger, toolCallLogger) {
842
936
  const { writePath: tempWritePath, winPath: tempWinPath } = getTempDir();
843
937
  ensureTempDir(tempWritePath);
938
+ const totalSlides = htmlResults.size;
939
+ const sortedEntries = [...htmlResults.entries()].sort((a, b) => a[0] - b[0]);
844
940
  const builtSlides = [];
845
- let failCount = 0;
846
941
  let totalToolCalls = 0;
847
942
  const tempFiles = [];
848
- const sortedEntries = [...htmlResults.entries()].sort((a, b) => a[0] - b[0]);
943
+ if (phaseLogger)
944
+ phaseLogger('powerpoint-create', 'assembly', `Pre-creating ${totalSlides} slides...`);
945
+ for (let i = 1; i < totalSlides; i++) {
946
+ await powerpointClient.powerpointAddSlide(7);
947
+ totalToolCalls++;
948
+ }
949
+ const slideFiles = new Map();
950
+ let slideNum = 0;
849
951
  for (const [index, result] of sortedEntries) {
850
- const slidePlan = plan.slides[index];
851
- const slideNum = builtSlides.length + 1;
852
- if (failCount >= 3) {
853
- logger.warn('Too many slide failures, stopping');
854
- break;
855
- }
856
- if (phaseLogger)
857
- phaseLogger('powerpoint-create', 'assembly', `Rendering slide ${slideNum}: ${slidePlan.title}`);
952
+ slideNum++;
858
953
  const htmlFileName = `hanseol_slide_${slideNum}_${timestamp}.html`;
859
954
  const pngFileName = `hanseol_slide_${slideNum}_${timestamp}.png`;
860
955
  const htmlWritePath = path.join(tempWritePath, htmlFileName);
@@ -862,101 +957,202 @@ async function assemblePresentation(htmlResults, plan, timestamp, savePath, phas
862
957
  const htmlWinPath = `${tempWinPath}\\${htmlFileName}`;
863
958
  const pngWinPath = `${tempWinPath}\\${pngFileName}`;
864
959
  try {
865
- const viewportHtml = result.isCodeTemplate
866
- ? injectEdgeSizing(result.html, plan.design.background_color)
867
- : injectViewportCss(result.html, plan.design.background_color);
960
+ const viewportHtml = injectEdgeSizing(result.html, plan.design.background_color);
868
961
  const processed = injectTitleContrastFix(viewportHtml, plan.design.text_color);
869
962
  fs.writeFileSync(htmlWritePath, processed, 'utf-8');
870
963
  tempFiles.push(htmlWritePath);
964
+ slideFiles.set(index, { slideNum, htmlWritePath, pngWritePath, htmlWinPath, pngWinPath });
871
965
  }
872
966
  catch (e) {
873
- logger.warn(`Slide ${slideNum}: Failed to write HTML file: ${e}`);
874
- failCount++;
875
- continue;
876
- }
877
- let renderSuccess = false;
878
- try {
879
- const renderResult = await powerpointClient.renderHtmlToImage(htmlWinPath, pngWinPath);
880
- totalToolCalls++;
881
- renderSuccess = renderResult.success;
882
- if (!renderSuccess) {
883
- await new Promise(r => setTimeout(r, 2000));
884
- const retryRender = await powerpointClient.renderHtmlToImage(htmlWinPath, pngWinPath);
885
- totalToolCalls++;
886
- renderSuccess = retryRender.success;
887
- }
967
+ logger.warn(`Slide ${slideNum}: Failed to write HTML: ${e}`);
888
968
  }
889
- catch (e) {
890
- logger.warn(`Slide ${slideNum}: Edge screenshot failed: ${e}`);
969
+ }
970
+ if (phaseLogger)
971
+ phaseLogger('powerpoint-create', 'assembly', `Rendering ${slideFiles.size} screenshots in parallel (batch ${MAX_SCREENSHOT_CONCURRENT})...`);
972
+ const screenshotEntries = [...slideFiles.entries()].sort((a, b) => a[0] - b[0]);
973
+ const screenshotSuccess = new Set();
974
+ for (let batch = 0; batch < screenshotEntries.length; batch += MAX_SCREENSHOT_CONCURRENT) {
975
+ const chunk = screenshotEntries.slice(batch, batch + MAX_SCREENSHOT_CONCURRENT);
976
+ const batchResults = await Promise.all(chunk.map(async ([index, files]) => {
977
+ let success = false;
891
978
  try {
892
- await new Promise(r => setTimeout(r, 2000));
893
- const retryRender = await powerpointClient.renderHtmlToImage(htmlWinPath, pngWinPath);
979
+ const result = await powerpointClient.renderHtmlToImage(files.htmlWinPath, files.pngWinPath);
894
980
  totalToolCalls++;
895
- renderSuccess = retryRender.success;
981
+ success = result.success;
982
+ if (!success) {
983
+ await new Promise(r => setTimeout(r, 1500));
984
+ const retry = await powerpointClient.renderHtmlToImage(files.htmlWinPath, files.pngWinPath);
985
+ totalToolCalls++;
986
+ success = retry.success;
987
+ }
896
988
  }
897
989
  catch {
898
- renderSuccess = false;
990
+ try {
991
+ await new Promise(r => setTimeout(r, 1500));
992
+ const retry = await powerpointClient.renderHtmlToImage(files.htmlWinPath, files.pngWinPath);
993
+ totalToolCalls++;
994
+ success = retry.success;
995
+ }
996
+ catch {
997
+ success = false;
998
+ }
899
999
  }
1000
+ try {
1001
+ fs.unlinkSync(files.htmlWritePath);
1002
+ }
1003
+ catch { }
1004
+ if (success) {
1005
+ try {
1006
+ const stat = fs.statSync(files.pngWritePath);
1007
+ if (stat.size < 15000) {
1008
+ logger.warn(`Slide ${files.slideNum}: Screenshot too small (${stat.size} bytes)`);
1009
+ success = false;
1010
+ try {
1011
+ fs.unlinkSync(files.pngWritePath);
1012
+ }
1013
+ catch { }
1014
+ }
1015
+ }
1016
+ catch { }
1017
+ }
1018
+ if (success)
1019
+ tempFiles.push(files.pngWritePath);
1020
+ return { index, success };
1021
+ }));
1022
+ for (const { index, success } of batchResults) {
1023
+ if (success)
1024
+ screenshotSuccess.add(index);
900
1025
  }
901
- try {
902
- fs.unlinkSync(htmlWritePath);
903
- }
904
- catch { }
905
- if (!renderSuccess) {
906
- logger.warn(`Slide ${slideNum}: Screenshot rendering failed, skipping`);
907
- failCount++;
1026
+ const done = Math.min(batch + MAX_SCREENSHOT_CONCURRENT, screenshotEntries.length);
1027
+ if (phaseLogger)
1028
+ phaseLogger('powerpoint-create', 'assembly', `Screenshots: ${done}/${screenshotEntries.length} done`);
1029
+ }
1030
+ if (phaseLogger)
1031
+ phaseLogger('powerpoint-create', 'assembly', `Filling ${totalSlides} slides...`);
1032
+ const unfilledSlideNums = [];
1033
+ for (const [index] of sortedEntries) {
1034
+ const files = slideFiles.get(index);
1035
+ const slidePlan = plan.slides[index];
1036
+ const htmlResult = htmlResults.get(index);
1037
+ if (!files) {
1038
+ const posInSorted = sortedEntries.findIndex(([idx]) => idx === index);
1039
+ const inferredSlideNum = posInSorted >= 0 ? posInSorted + 1 : -1;
1040
+ logger.warn(`Slide index ${index} (slideNum ${inferredSlideNum}): No file info, marking for deletion`);
1041
+ if (inferredSlideNum > 0)
1042
+ unfilledSlideNums.push(inferredSlideNum);
908
1043
  continue;
909
1044
  }
910
- try {
911
- const pngStats = fs.statSync(pngWritePath);
912
- if (pngStats.size < 15000) {
913
- logger.warn(`Slide ${slideNum}: Screenshot too small (${pngStats.size} bytes), likely blank — skipping`);
914
- failCount++;
1045
+ let filled = false;
1046
+ if (screenshotSuccess.has(index)) {
1047
+ const bgResult = await powerpointClient.powerpointAddFullSlideImage(files.slideNum, files.pngWinPath);
1048
+ totalToolCalls++;
1049
+ if (toolCallLogger)
1050
+ toolCallLogger('powerpoint-create', 'addFullSlideImage', { slideNum: files.slideNum, imagePath: files.pngWinPath }, bgResult.success ? 'OK' : 'Failed', bgResult.success, files.slideNum, totalToolCalls);
1051
+ filled = bgResult.success;
1052
+ }
1053
+ if (!filled) {
1054
+ logger.info(`Slide ${files.slideNum}: Primary screenshot failed, trying fallback rendering...`);
1055
+ const slide = plan.slides[index];
1056
+ const fallbackHtml = buildFallbackSlideHtml(plan.design, slide.title, slide.content_direction || '', files.slideNum);
1057
+ const fbHtmlName = `hanseol_fb_${files.slideNum}_${timestamp}.html`;
1058
+ const fbPngName = `hanseol_fb_${files.slideNum}_${timestamp}.png`;
1059
+ const fbHtmlWrite = path.join(tempWritePath, fbHtmlName);
1060
+ const fbPngWrite = path.join(tempWritePath, fbPngName);
1061
+ const fbHtmlWin = `${tempWinPath}\\${fbHtmlName}`;
1062
+ const fbPngWin = `${tempWinPath}\\${fbPngName}`;
1063
+ try {
1064
+ const viewportHtml = injectEdgeSizing(fallbackHtml, plan.design.background_color);
1065
+ fs.writeFileSync(fbHtmlWrite, viewportHtml, 'utf-8');
1066
+ const fbResult = await powerpointClient.renderHtmlToImage(fbHtmlWin, fbPngWin);
1067
+ totalToolCalls++;
915
1068
  try {
916
- fs.unlinkSync(pngWritePath);
1069
+ fs.unlinkSync(fbHtmlWrite);
1070
+ }
1071
+ catch { }
1072
+ if (fbResult.success) {
1073
+ const bgResult = await powerpointClient.powerpointAddFullSlideImage(files.slideNum, fbPngWin);
1074
+ totalToolCalls++;
1075
+ filled = bgResult.success;
1076
+ try {
1077
+ fs.unlinkSync(fbPngWrite);
1078
+ }
1079
+ catch { }
1080
+ }
1081
+ }
1082
+ catch (e) {
1083
+ logger.warn(`Slide ${files.slideNum}: Fallback rendering also failed: ${e}`);
1084
+ try {
1085
+ fs.unlinkSync(fbHtmlWrite);
917
1086
  }
918
1087
  catch { }
919
- continue;
920
1088
  }
921
1089
  }
922
- catch { }
923
- tempFiles.push(pngWritePath);
924
- const addResult = await powerpointClient.powerpointAddSlide(7);
925
- totalToolCalls++;
926
- if (!addResult.success) {
927
- logger.warn(`Slide ${slideNum}: Failed to add blank slide`);
928
- failCount++;
929
- continue;
930
- }
931
- const bgResult = await powerpointClient.powerpointAddFullSlideImage(slideNum, pngWinPath);
932
- totalToolCalls++;
933
- if (toolCallLogger)
934
- toolCallLogger('powerpoint-create', 'addFullSlideImage', { slideNum, imagePath: pngWinPath }, bgResult.success ? 'OK' : 'Failed', bgResult.success, slideNum, totalToolCalls);
935
- if (bgResult.success) {
936
- builtSlides.push(`Slide ${slideNum}: ${slidePlan.title} (${slidePlan.type})`);
1090
+ if (filled) {
1091
+ builtSlides.push(`Slide ${files.slideNum}: ${slidePlan.title} (${slidePlan.type})`);
937
1092
  try {
938
- await powerpointClient.powerpointAddNote(slideNum, result.html);
1093
+ await powerpointClient.powerpointAddNote(files.slideNum, htmlResult.html);
939
1094
  }
940
1095
  catch { }
941
1096
  }
942
1097
  else {
943
- logger.warn(`Slide ${slideNum}: Failed to set background: ${JSON.stringify(bgResult)}`);
944
- failCount++;
1098
+ logger.warn(`Slide ${files.slideNum}: All rendering attempts failed, marking for deletion`);
1099
+ unfilledSlideNums.push(files.slideNum);
945
1100
  }
946
1101
  }
947
- if (builtSlides.length > 0) {
1102
+ if (unfilledSlideNums.length > 0) {
1103
+ const sortedDesc = [...unfilledSlideNums].sort((a, b) => b - a);
1104
+ for (const slideNum of sortedDesc) {
1105
+ try {
1106
+ await powerpointClient.powerpointDeleteSlide(slideNum);
1107
+ totalToolCalls++;
1108
+ logger.info(`Deleted unfilled slide ${slideNum}`);
1109
+ }
1110
+ catch (e) {
1111
+ logger.warn(`Failed to delete unfilled slide ${slideNum}: ${e}`);
1112
+ }
1113
+ }
1114
+ }
1115
+ const hasClosingInPlan = plan.slides.some(s => s.type === 'closing');
1116
+ const hasClosingBuilt = builtSlides.some(s => s.includes('(closing)'));
1117
+ if (hasClosingInPlan && !hasClosingBuilt && builtSlides.length > 0) {
1118
+ logger.info('Closing slide was lost during assembly — adding it now');
948
1119
  try {
949
- const slideCountResult = await powerpointClient.powerpointGetSlideCount();
950
- const totalSlidesInPpt = slideCountResult['slide_count'] || 0;
951
- if (totalSlidesInPpt > builtSlides.length) {
952
- for (let d = totalSlidesInPpt; d > builtSlides.length; d--) {
953
- await powerpointClient.powerpointDeleteSlide(d);
954
- totalToolCalls++;
1120
+ await powerpointClient.powerpointAddSlide(7);
1121
+ totalToolCalls++;
1122
+ const slideCountRes = await powerpointClient.powerpointGetSlideCount();
1123
+ const newSlideNum = slideCountRes['slide_count'] || builtSlides.length + 1;
1124
+ const closingPlan = plan.slides.find(s => s.type === 'closing');
1125
+ const closingTagline = (closingPlan.content_direction || '').replace(/감사합니다|thank\s*you/gi, '').trim() || undefined;
1126
+ const closingHtml = buildClosingSlideHtml(plan.design, companyName, newSlideNum, language, closingTagline);
1127
+ const clHtmlName = `hanseol_closing_${timestamp}.html`;
1128
+ const clPngName = `hanseol_closing_${timestamp}.png`;
1129
+ const clHtmlWrite = path.join(tempWritePath, clHtmlName);
1130
+ const clPngWrite = path.join(tempWritePath, clPngName);
1131
+ const clHtmlWin = `${tempWinPath}\\${clHtmlName}`;
1132
+ const clPngWin = `${tempWinPath}\\${clPngName}`;
1133
+ const viewportHtml = injectEdgeSizing(closingHtml, plan.design.background_color);
1134
+ fs.writeFileSync(clHtmlWrite, viewportHtml, 'utf-8');
1135
+ const ssResult = await powerpointClient.renderHtmlToImage(clHtmlWin, clPngWin);
1136
+ totalToolCalls++;
1137
+ try {
1138
+ fs.unlinkSync(clHtmlWrite);
1139
+ }
1140
+ catch { }
1141
+ if (ssResult.success) {
1142
+ const bgResult = await powerpointClient.powerpointAddFullSlideImage(newSlideNum, clPngWin);
1143
+ totalToolCalls++;
1144
+ if (bgResult.success) {
1145
+ builtSlides.push(`Slide ${newSlideNum}: ${closingPlan.title} (closing)`);
1146
+ logger.info('Closing slide added successfully');
1147
+ }
1148
+ try {
1149
+ fs.unlinkSync(clPngWrite);
955
1150
  }
1151
+ catch { }
956
1152
  }
957
1153
  }
958
1154
  catch (e) {
959
- logger.warn(`Failed to clean up trailing slides: ${e}`);
1155
+ logger.warn(`Failed to add closing slide: ${e}`);
960
1156
  }
961
1157
  }
962
1158
  if (builtSlides.length > 0) {
@@ -1069,7 +1265,8 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1069
1265
  if (/로고|슬로건|연락처|contact|logo|placeholder/i.test(titleSubtitle)) {
1070
1266
  titleSubtitle = '';
1071
1267
  }
1072
- const companyMatch = instruction.match(/회사명\s*[::]\s*([^\s,,、]+)/);
1268
+ const cleanInstruction = instruction.replace(/\*\*/g, '');
1269
+ const companyMatch = cleanInstruction.match(/회사명\s*[::]?\s*([^\s,,、]+)/);
1073
1270
  if (companyMatch && companyMatch[1]) {
1074
1271
  const companyName_ = companyMatch[1];
1075
1272
  if (titleSlidePlan && titleSlidePlan.title.trim() !== companyName_) {
@@ -1083,13 +1280,14 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1083
1280
  }
1084
1281
  }
1085
1282
  }
1283
+ rebalanceLayouts(plan);
1086
1284
  if (phaseLogger)
1087
1285
  phaseLogger('powerpoint-create', 'html-generation', 'Starting parallel HTML generation...');
1088
1286
  const htmlResults = await generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstDate, language, phaseLogger);
1089
1287
  const validatedResults = await validateAndRegenerate(llmClient, htmlResults, plan, language, phaseLogger);
1090
1288
  if (phaseLogger)
1091
1289
  phaseLogger('powerpoint-create', 'assembly', `Assembling ${validatedResults.size} slides into PowerPoint...`);
1092
- const { builtSlides, totalToolCalls } = await assemblePresentation(validatedResults, plan, timestamp, savePath, phaseLogger, toolCallLogger);
1290
+ const { builtSlides, totalToolCalls } = await assemblePresentation(validatedResults, plan, timestamp, savePath, companyName, language, phaseLogger, toolCallLogger);
1093
1291
  const duration = Date.now() - startTime;
1094
1292
  const slideList = builtSlides.join('\n');
1095
1293
  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}`;