-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPresentationTool.cs
More file actions
654 lines (592 loc) · 38.5 KB
/
Copy pathPresentationTool.cs
File metadata and controls
654 lines (592 loc) · 38.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Globalization;
using HtmlAgilityPack;
using SixLabors.ImageSharp;
using System.Diagnostics;
using UISupportGeneric;
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("PresentationTool.Harness")]
namespace AIOrchestrator.API
{
/// <summary>Presentation operations for agent use: create and update a self-contained HTML deck from a description or change request. File paths are Unix-style, relative to the workspace root — never escape it.</summary>
public class PresentationTool : BaseAgentTool, IFileTool
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private static readonly string[] Styles = ["Modern", "Vintage", "Minimalist White", "Brutalist", "Retro Pop", "Vaporwave", "Biophilic Design", "Cyberpunk Neon", "Glassmorphism", "Bento Grid", "Retro"];
private const int MaxHtmlAttempts = 3;
private const string OnlyOutputAnswer = "- Output only full HTML code. No opening or closing comments, no fences. [Output only]";
/// <summary>Registers the resolver for the [[available_styles]] dynamic placeholder: the tool
/// description (param "style") always shows the current style list, in sync with the code.</summary>
static PresentationTool()
{
Analyzer.DynamicDescriptionRequested += (_, e) =>
{
if (e.ToolType == typeof(PresentationTool) && e.Placeholder == "available_styles")
e.Value = string.Join(", ", Styles);
};
}
/// <summary>Generate a PowerPoint-style presentation</summary>
/// <param name="description">What the presentation must cover. The description must include: the subject, a descriptive title and the purpose of the presentation (e.g. "Present the Q3 2026 sales results, titled 'Record Quarter', to the management team — 5 slides"). Keep it a guideline: put the supporting material in contextText or contextFile, otherwise the tool rejects the request for lack of material.</param>
/// <param name="style">Optional graphic style that shapes the deck. Available styles: [[available_styles]].</param>
/// <param name="outputTwoLetterLanguage">Optional two-letter language code for the presentation (e.g. "en", "fr"); if omitted, the context data language is used.</param>
/// <param name="contextText">Optional context text the deck content must be based on. (Mandatory if contextFile is missing)</param>
/// <param name="contextFile">Optional workspace file read as content context (Unix-style path, e.g. "/docs/report.md"). (Mandatory if contextText is missing)</param>
/// <param name="imageFiles">Optional workspace image files to place in the deck (Unix-style paths, e.g. "/images/chart.png"). Each image is used at most once.</param>
/// <param name="saveFullNameFile">Optional output file path and name (Unix-style, must end with ".html", e.g. "/out/sales.html"). Default: "/presentation/presentation_yyyyMMdd_HHmmss.html" in the workspace.</param>
/// <returns>The generated .html path in workspace form, or an "Error: ..." message (missing input, unsupported image type, insufficient context, unclear description, LLM failure).</returns>
public string CreatePresentation(string description, string? style = null, string? contextText = null, string? contextFile = null, string[]? imageFiles = null, string? saveFullNameFile = null, string? outputTwoLetterLanguage = null)
{
if (string.IsNullOrWhiteSpace(description))
return "Error: description is required.";
if (saveFullNameFile != null && !saveFullNameFile.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
return "Error: saveFullNameFile must end with '.html' (the presentation is saved as a self-contained HTML file).";
string hostPath;
try
{
hostPath = SandboxPath.Resolve(saveFullNameFile
?? $"/presentation/presentation_{DateTime.Now:yyyyMMdd_HHmmss}.html");
}
catch (UnauthorizedAccessException ex) { return $"Error: {ex.Message}"; }
Directory.CreateDirectory(Path.GetDirectoryName(hostPath)!);
var context = new StringBuilder();
var contextFiles = new List<string>();
if (!string.IsNullOrWhiteSpace(contextText)) context.AppendLine(contextText);
if (!string.IsNullOrWhiteSpace(contextFile))
{
string ctxHost;
try { ctxHost = SandboxPath.Resolve(contextFile); }
catch (UnauthorizedAccessException ex) { return $"Error: {ex.Message}"; }
if (!File.Exists(ctxHost)) return $"Error: context file '{contextFile}' not found in the workspace.";
contextFiles.Add(SandboxPath.ToAgent(ctxHost));
context.AppendLine(ReadTextCapped(ctxHost, 60_000));
}
var (images, imagesError) = ResolveImages(imageFiles);
if (imagesError != null) return imagesError;
style ??= Styles[Random.Shared.Next(Styles.Length)];
Log.LogStep($"PresentationTool.CreatePresentation: description='{Truncate(description, 120)}' style={style} images={images!.Count} contextLen={context.Length}");
var opinion = AskOpinion(description, context.ToString());
if (opinion == null) return "Error: the LLM returned no usable evaluation of the request. Retry later.";
if (!opinion.Sufficient || !opinion.DescriptionClear)
return BuildInsufficientError(opinion);
var html = GenerateHtml(BuildCreatePrompt(description, style, contextFiles, context.ToString(), images, outputTwoLetterLanguage));
if (html == null) return $"Error: the LLM returned no usable HTML after {MaxHtmlAttempts} attempts. Retry later.";
html = EnsureImagesUsed(html, images);
var improved = GenerateHtml(ImproveHtmlCode(html, style));
# if DEBUG
if (improved == null)
Debugger.Break();
#endif
if (improved != null) html = improved;
else Log.LogStep("PresentationTool.CreatePresentation: styling pass failed, keeping first-pass HTML");
var checkedHtml = GenerateHtml(BuildCheckFixPrompt(html));
if (checkedHtml != null) html = checkedHtml;
else Log.LogStep("PresentationTool.CreatePresentation: check&fix pass failed, keeping previous HTML");
html = EnsureImagesUsed(html, images); // re-check: later passes may have dropped the images
html = EmbedImages(html, images);
html = EmbedSvgIcons(html);
html = InjectFixContentSizeScript(html);
html = InjectAnimatedBackground(html, style);
string? versionId = null;
try
{
File.WriteAllText(hostPath, html);
versionId = GitSupport.Snapshot(hostPath, "PresentationTool create");
}
catch (Exception ex)
{
Log.LogStep($"PresentationTool.CreatePresentation: failed '{hostPath}': {ex}");
return "Error: cannot save the presentation (write failed). Retry later.";
}
Log.LogStep($"PresentationTool.CreatePresentation: wrote '{hostPath}' ({html.Length} chars) version='{versionId}'");
return versionId != null
? $"Presentation created at {SandboxPath.ToAgent(hostPath)}. New version: {versionId}. (Rollback via GitTool.restore.)"
: $"Presentation created at {SandboxPath.ToAgent(hostPath)}.";
}
/// <summary>Updates an existing presentation on request (e.g. change a slide, recolor the deck, add or remove content): the requested changes are applied and the file is overwritten in place. The new content becomes a new version (rollback via GitTool.restore).</summary>
/// <param name="filePath">Path of the presentation to update (Unix-style, e.g. "/presentation/sales.html").</param>
/// <param name="changes">The changes to apply (e.g. "shorten slide 3, change the colors, add a summary slide at the end").</param>
/// <param name="contextText">Optional extra context the update must respect.</param>
/// <param name="imageFiles">Optional workspace image files the update must place in the deck (Unix-style paths, e.g. "/images/chart.png"), same semantics as in CreatePresentation: each image is used at most once.</param>
/// <returns>The updated .html path in workspace form (with the backup name), or an "Error: ..." message (missing input, unclear changes, LLM failure).</returns>
public string UpdatePresentation(string filePath, string changes, string? contextText = null, string[]? imageFiles = null)
{
if (string.IsNullOrWhiteSpace(changes)) return "Error: changes is required.";
string hostPath;
try { hostPath = SandboxPath.Resolve(filePath); }
catch (UnauthorizedAccessException ex) { return $"Error: {ex.Message}"; }
if (!File.Exists(hostPath)) return $"Error: file '{filePath}' not found in the workspace.";
if (!hostPath.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
return $"Error: only .html presentations can be updated (they are generated by {AIOrchestrator.Utility.ToSnakeCase(nameof(CreatePresentation))}).";
string currentHtml;
try { currentHtml = File.ReadAllText(hostPath); }
catch (Exception ex)
{
Log.LogStep($"PresentationTool.UpdatePresentation: cannot read '{hostPath}': {ex}");
return "Error: cannot read the presentation (the file may be corrupted).";
}
var (images, imagesError) = ResolveImages(imageFiles);
if (imagesError != null) return imagesError;
Log.LogStep($"PresentationTool.UpdatePresentation: '{hostPath}' changes='{Truncate(changes, 120)}' contextText='{Truncate(contextText ?? "", 120)}' images={images!.Count}");
var verdict = AskChangesClear(changes, contextText, images);
if (verdict == null) return "Error: the LLM returned no usable evaluation of the changes. Retry later.";
if (!verdict.Clear)
return $"Error: the requested changes are not clear enough to apply. {Reasons(verdict.Explanation, "the changes do not say what to modify")}";
var html = UpdateHtmlCore(currentHtml, changes, contextText, images);
if (html == null) return $"Error: the LLM returned no usable HTML after {MaxHtmlAttempts} attempts. Retry later.";
html = EmbedImages(html, images);
html = EmbedSvgIcons(html);
string? versionId = null;
try
{
File.WriteAllText(hostPath, html);
versionId = GitSupport.Snapshot(hostPath, "PresentationTool update");
}
catch (Exception ex)
{
Log.LogStep($"PresentationTool.UpdatePresentation: failed '{hostPath}': {ex}");
return "Error: cannot apply the changes (write failed). Retry later.";
}
Log.LogStep($"PresentationTool.UpdatePresentation: '{hostPath}' updated ({html.Length} chars) version='{versionId}'");
return versionId != null
? $"Presentation updated at {SandboxPath.ToAgent(hostPath)}. New version: {versionId}. (Rollback via GitTool.restore.)"
: $"Presentation updated at {SandboxPath.ToAgent(hostPath)}.";
}
// ---------- LLM ----------
/// <summary>Resolves the optional image files to host paths, validating existence and type.
/// A path that does not exist is retried as a bare file name (searched across the whole
/// workspace) — the same tolerance Restore applies to backup names: agents often pass just
/// the file name instead of the full Unix-style path. Returns (images, null) on success or
/// (null, error message) on the first bad entry.</summary>
private static (List<string>? Images, string? Error) ResolveImages(string[]? imageFiles)
{
var images = new List<string>();
if (imageFiles == null) return (images, null);
foreach (var img in imageFiles.Where(f => !string.IsNullOrWhiteSpace(f)))
{
string imgHost;
try { imgHost = SandboxPath.Resolve(img); }
catch (UnauthorizedAccessException ex) { return (null, $"Error: {ex.Message}"); }
if (!File.Exists(imgHost))
{
// bare or mis-prefixed path: search the workspace by file name
imgHost = Directory.GetFiles(Setup.DocumentsPath, Path.GetFileName(img), SearchOption.AllDirectories)
.FirstOrDefault() ?? imgHost;
}
if (!File.Exists(imgHost))
return (null, $"Error: image file '{img}' not found in the workspace. Pass the file's Unix-style workspace path (e.g. '/images/chart.png').");
if (MimeFor(imgHost) == null) return (null, $"Error: unsupported image type for '{img}'. Use png, jpg, gif, bmp, svg or webp.");
images.Add(imgHost);
}
return (images, null);
}
/// <summary>Checks the first-pass deck for each context image by searching the file name as a
/// string; any image the LLM did not reference is forced into the deck through the update flow
/// (the changes text instructs to insert them where most appropriate).</summary>
private static string EnsureImagesUsed(string html, List<string> images)
{
if (images.Count == 0) return html;
var missing = images.Where(i => !html.Contains(Path.GetFileName(i), StringComparison.OrdinalIgnoreCase)).ToList();
if (missing.Count == 0)
{
Log.LogStep("PresentationTool.CreatePresentation: all context images referenced in the first pass");
return html;
}
Log.LogStep($"PresentationTool.CreatePresentation: forcing {missing.Count} missing image(s) via update flow: " +
string.Join(", ", missing.Select(Path.GetFileName)));
const string changes = "These images are part of the presentation and should be inserted into the html page where most appropriate.";
return UpdateHtmlCore(html, changes, null, images) ?? html;
}
/// <summary>Shared LLM core of the update flow: generates the updated deck from the current
/// HTML plus the requested changes, then runs the check&fix pass. Returns null when the
/// LLM cannot produce valid HTML. Embedding (images/icons) is left to the callers.</summary>
private static string? UpdateHtmlCore(string currentHtml, string changes, string? contextText, List<string> images)
{
var html = GenerateHtml(BuildUpdatePrompt(currentHtml, changes, contextText, images));
if (html == null) return null;
var checkedHtml = GenerateHtml(BuildCheckFixPrompt(html));
if (checkedHtml != null) html = checkedHtml;
else Log.LogStep("PresentationTool.UpdatePresentation: check&fix pass failed, keeping updated HTML");
return html;
}
/// <summary>Asks the LLM (no history) whether the given context and description are enough to build the deck; returns the JSON verdict or null when the LLM fails.</summary>
private static SufficiencyOpinion? AskOpinion(string description, string context)
{
using var llm = new LLMUtility(Setup.ProviderConfig.ProviderName);
var prompt = $$"""
Today's date: {{DateTime.Now:yyyy-MM-dd}}
You check whether the material provided is sufficient to fulfill the presentation request.
Check if the context material is sufficient to create a presentation as specified in the description.
Description of the task to be performed (description):
```text
{{(string.IsNullOrWhiteSpace(description) ? "(none provided)" : description)}}
```
Material (context):
```text
{{(string.IsNullOrWhiteSpace(context) ? "(none provided)" : context)}}
```
Respond with ONLY JSON (no fences, no commentary):
{"sufficient": true|false, "descriptionClear": true|false, "explanation": ["what is missing or unclear", ...]}
- "sufficient" = false when the background material is not sufficient for a powerpoint presentation that fulfills the description's requirements.
- "descriptionClear" = false when the description is ambiguous (unclear subject, title or purpose).
- "explanation" lists the concrete missing/unclear items when a flag is false; empty otherwise.
""";
var (response, hResult) = llm.SendQuery(prompt, useHistory: false, role: LLMUtility.SystemRole.DocumentPreparer,
forceJsonResponse: true);
if (hResult != null || string.IsNullOrWhiteSpace(response)) return null;
var opinion = TryParseJson<SufficiencyOpinion>(response);
if (opinion == null) Log.LogStep($"PresentationTool.AskOpinion: unparseable JSON response");
else Log.LogStep($"PresentationTool.AskOpinion: sufficient={opinion.Sufficient} descriptionClear={opinion.DescriptionClear}");
return opinion;
}
/// <summary>Asks the LLM (no history) whether the requested changes (and optional context)
/// are clear enough to apply. The provided images are made known to the evaluator: a request
/// that refers to an available image (e.g. "add the logo") is clear — without this the gate
/// would reject it as ambiguous. Returns the JSON verdict or null when the LLM fails.</summary>
private static ChangeVerdict? AskChangesClear(string changes, string? contextText, List<string> images)
{
using var llm = new LLMUtility(Setup.ProviderConfig.ProviderName);
var prompt = $$"""
Today's date: {{DateTime.Now:yyyy-MM-dd}}
You are about to edit an existing presentation. Validate that the requested changes are clear enough to apply.
Rules:
- Decide sensible details yourself (e.g. which slide is "the first", which shade of blue, what "colors" means) — these do NOT make the request unclear.
- Reject ONLY when the changes are genuinely unusable: empty, meaningless or contradictory requests.
{{(!string.IsNullOrWhiteSpace(contextText) ? "Additional context: " + contextText : "")}}
{{(images.Count > 0 ? "Available images (the request may refer to them by file name): " + string.Join(", ", images.Select(Path.GetFileName)) : "")}}
Requested changes: {{changes}}
Respond with ONLY JSON (no fences, no commentary):
{"clear": true|false, "explanation": ["what is missing or unclear", ...]}
- "clear" = false ONLY when the changes cannot be interpreted at all.
- "explanation" lists what is unclear when "clear" is false; empty array otherwise.
""";
var (response, hResult) = llm.SendQuery(prompt, useHistory: false, role: LLMUtility.SystemRole.DocumentPreparer,
forceJsonResponse: true);
if (hResult != null || string.IsNullOrWhiteSpace(response)) return null;
var verdict = TryParseJson<ChangeVerdict>(response);
if (verdict == null) Log.LogStep($"PresentationTool.AskChangesClear: unparseable JSON response");
else Log.LogStep($"PresentationTool.AskChangesClear: clear={verdict.Clear}");
return verdict;
}
/// <summary>Generates deck HTML via the LLM (no history), validates it as HTML5 and retries up to <see cref="MaxHtmlAttempts"/> times, feeding back the validation errors. Returns null when all attempts fail.</summary>
private static string? GenerateHtml(string prompt)
{
using var llm = new LLMUtility(Setup.ProviderConfig.ProviderName);
for (int attempt = 1; attempt <= MaxHtmlAttempts; attempt++)
{
Log.LogStep($"PresentationTool.GenerateHtml: attempt {attempt}/{MaxHtmlAttempts}");
var (response, hResult) = llm.SendQuery(prompt, useHistory: false, role: LLMUtility.SystemRole.DocumentPreparer);
if (hResult != null)
{
Log.LogStep($"PresentationTool.GenerateHtml: LLM error hResult={hResult} on attempt {attempt} — retrying");
continue;
}
if (string.IsNullOrWhiteSpace(response)) continue;
var html = response;
if (!Utility.RemoveFencesEncapsulationAndFixTrim(ref html, false))
{
Log.LogStep($"PresentationTool.GenerateHtml: malformed fences on attempt {attempt}");
continue;
}
if (IsValidHtml5(html, out var errors))
{
Log.LogStep($"PresentationTool.GenerateHtml: valid HTML on attempt {attempt}");
return html;
}
Log.LogStep($"PresentationTool.GenerateHtml: invalid HTML on attempt {attempt} ({errors.Count} errors: {string.Join(" | ", errors.Take(6))})");
if (attempt == MaxHtmlAttempts) break;
var errorFeedback = string.Join("\n", errors.Take(8).Select(e => $" - {e}"));
var missingEnd = !html.TrimEnd().EndsWith("</html>", StringComparison.OrdinalIgnoreCase);
var truncated = missingEnd && html.Length > 20000;
prompt = $"""
The previous HTML5 code you provided was not valid.
Here is the code that failed:
```html
{html}
```
Validation errors:
{errorFeedback}
{(truncated ? "The output was truncated by the output limit: the closing tags are missing at the end of the document. Produce a MORE COMPACT version that fits in the output limit: keep ALL the content but compress the markup (shorter inline styles, fewer wrapper elements). The output MUST end with the closing </html> tag." : missingEnd ? "The document does not end with the closing </html> tag." : "")}
Please fix ALL the errors above and provide a corrected, valid HTML5 version.
{OnlyOutputAnswer}
""";
}
return null;
}
private static string BuildCreatePrompt(string description, string style, List<string> contextFiles, string context, List<string> images, string? outputTwoLetterLanguage = null)
{
// auto detect language if not specified: from the context, else from the first context file
if (string.IsNullOrWhiteSpace(outputTwoLetterLanguage))
{
outputTwoLetterLanguage = Utility.DetectLanguage(context);
if (outputTwoLetterLanguage == null && contextFiles.Count > 0)
{
try { outputTwoLetterLanguage = Utility.DetectLanguage(ReadTextCapped(SandboxPath.Resolve(contextFiles[0]), 60_000)); }
catch (UnauthorizedAccessException) { }
}
outputTwoLetterLanguage ??= "en";
}
string languageName;
try { languageName = new CultureInfo(outputTwoLetterLanguage).EnglishName; }
catch (CultureNotFoundException) { languageName = "English"; }
var sb = new StringBuilder();
sb.AppendLine("Today's date: " + DateTime.Now.ToString("yyyy-MM-dd"));
sb.AppendLine($"Create a website of presentation in {languageName} using these context documents.");
sb.AppendLine("Presentation description:");
sb.AppendLine("```text");
sb.AppendLine(description);
sb.AppendLine("```");
sb.AppendLine("Create as many slides as needed for your purpose (add or remove .slide elements in the template as needed).");
if (contextFiles.Count > 0)
{
sb.AppendLine("Context documents (workspace paths):");
foreach (var p in contextFiles) sb.AppendLine("- " + p);
}
if (!string.IsNullOrWhiteSpace(context))
{
sb.AppendLine("Context content:");
sb.AppendLine("```text");
sb.AppendLine(context.TrimEnd());
sb.AppendLine("```");
}
sb.AppendLine();
sb.AppendLine("- To make communication more effective you can insert into the slides: Tables, Kanban Boards, Timelines, Roadmaps, Organizational Charts, Flowcharts, Venn Diagrams, SWOT Analysis grids, PESTLE Analysis frameworks, Decision Trees, and other useful elements.");
sb.AppendLine("- Don't use small fonts.");
if (images.Count > 0)
{
sb.AppendLine();
sb.AppendLine("Available images (reference by file name only, e.g. <img src=\"chart.png\">):");
sb.AppendLine(FileManager.GetFilesInfo(images.Select(SandboxPath.ToAgent)));
sb.AppendLine("- Use each image once");
sb.AppendLine();
}
sb.AppendLine("- Use square SVG icons with a self-explanatory file name that can encode size and color: <icon-name>.<size>.<rrggbb>.svg (these files will be auto-generated based on the name you give them). Usage example: disc.32.aa0000.svg (a disc icon, 32x32 px, hex color #aa0000) → <img src=\"disc.32.aa0000.svg\" alt=\"disc\">");
sb.AppendLine($"- Use this template with a \"{style.ToLower()}\" style (keep its CSS classes, light and dark theme, navigation buttons and script unchanged; fill the .slide elements with the deck slides):");
sb.AppendLine("```html");
sb.AppendLine(TemplateHtml);
sb.AppendLine("```");
sb.AppendLine("- Write the content in the language of the description.");
sb.AppendLine("- The output MUST be in HTML format");
sb.AppendLine("- Check before output");
sb.AppendLine(OnlyOutputAnswer);
return sb.ToString();
}
private static string ImproveHtmlCode(string currentHtml, string style)
{
var sb = new StringBuilder();
sb.AppendLine("Improve HTML code with these enhancements:");
sb.AppendLine($"- Implement a \"{style.ToUpper()}\" design on the page");
sb.AppendLine("- Add effects and transitions to slide elements");
sb.AppendLine("- Use JavaScript to create amazing slide graphics.");
// sb.AppendLine("- Preserve the basic slide structure.");
sb.AppendLine("HTML code:");
sb.AppendLine("```html");
sb.AppendLine(currentHtml);
sb.AppendLine("```");
sb.AppendLine(OnlyOutputAnswer);
return sb.ToString();
}
private static string BuildCheckFixPrompt(string currentHtml)
{
var sb = new StringBuilder();
sb.AppendLine("* Check and fix the following:");
sb.AppendLine("- There should be no small fonts.");
sb.AppendLine("- Content must fit the slide container: (Verify the dimensions mathematically, and fix the content if it goes off the slide).");
sb.AppendLine("- Check for both light and dark theme the correctness of the contrast between the text color and the background (fix if necessary).");
// sb.AppendLine("- Preserve the basic slide structure.");
sb.AppendLine("HTML code:");
sb.AppendLine("```html");
sb.AppendLine(currentHtml);
sb.AppendLine("```");
sb.AppendLine(OnlyOutputAnswer);
return sb.ToString();
}
private static string BuildUpdatePrompt(string currentHtml, string changes, string? contextText, List<string> images)
{
var sb = new StringBuilder();
sb.AppendLine("Today's date: " + DateTime.Now.ToString("yyyy-MM-dd"));
sb.AppendLine("Edit an existing PowerPoint presentation (16:9 deck) rendered as a single HTML file.");
sb.AppendLine("Apply the requested changes LITERALLY to the HTML below:");
sb.AppendLine("- The exact strings in the changes request (titles, labels, text) MUST appear verbatim in the output — do not reword or replace them.");
sb.AppendLine("- Change ONLY what the changes request; keep the rest of the content, wording and structure identical.");
sb.AppendLine("- Keep the template's CSS classes, the language, navigation buttons and script unchanged; edit the .slide elements (and styles only if needed).");
sb.AppendLine();
sb.AppendLine("Current presentation HTML:");
sb.AppendLine("```html");
sb.AppendLine(currentHtml);
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine("Requested changes: " + changes);
if (images.Count > 0)
{
sb.AppendLine();
sb.AppendLine("Available images (reference by file name only, e.g. <img src=\"chart.png\">):");
sb.AppendLine(FileManager.GetFilesInfo(images.Select(SandboxPath.ToAgent)));
sb.AppendLine("- Use each image once");
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(contextText)) sb.AppendLine("Additional context: " + contextText);
sb.AppendLine("You may add SVG icons with a minimalist self-descriptive file name, such as <icon-name>.svg (these files will be auto-generated based on the minimalist name you give them).");
sb.AppendLine("Write the content in the language of the presentation.");
sb.AppendLine(OnlyOutputAnswer);
return sb.ToString();
}
private static string BuildInsufficientError(SufficiencyOpinion opinion)
{
var reasons = Reasons(opinion.Explanation, "the context does not cover the requested topic");
if (!opinion.Sufficient && !opinion.DescriptionClear)
return $"Error: the context is not sufficient and the description is not clear enough to create the presentation. {reasons}";
if (!opinion.Sufficient)
return $"Error: the context is not sufficient to create the presentation. {reasons}";
return $"Error: the description is not clear enough to create the presentation. {reasons}";
}
private static string Reasons(List<string>? explanation, string fallback) =>
explanation is { Count: > 0 }
? string.Join(" ", explanation.Select(e => "- " + e))
: "- " + fallback;
// ---------- HTML post-processing ----------
/// <summary>Replaces every src reference to a provided image with an inline data URI, so the
/// deck is self-contained. The reference may be a bare file name or a path ending with it
/// (src="logo.png", src="./img/logo.png", src="/img/logo.png").</summary>
private static string EmbedImages(string html, List<string> images)
{
foreach (var img in images)
{
var name = Path.GetFileName(img);
var dataUri = "data:" + MimeFor(img) + ";base64," + Convert.ToBase64String(File.ReadAllBytes(img));
html = Regex.Replace(html,
$@"src=[""'](?:[^""'/]*/)*{Regex.Escape(name)}[""']",
m => $"src=\"{dataUri}\"", RegexOptions.IgnoreCase);
}
return html;
}
/// <summary>Replaces every "<icon-name>[.<size>].[.<rrggbb>].svg" img placeholder with the
/// matching icon from the host assets, encoded as a data URI (shared logic in
/// Utility.EmbedSvgIcons — same pipeline as the document cover render in MD2PDF). The
/// reference may be a bare name or a path ending with it.</summary>
internal static string EmbedSvgIcons(string html)
{
var iconsPath = Path.Combine(AppContext.BaseDirectory, "assets", "icons");
return Utility.EmbedSvgIcons(html, iconsPath);
}
/// <summary>Injects the content-size fix script before </body> (the asset is a complete
/// HTML snippet, inserted as-is). Missing asset is a no-op: the enhancement is optional and
/// must never fail the deck creation.</summary>
internal static string InjectFixContentSizeScript(string html)
{
try
{
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "assets", "fix-content-size.js"), Encoding.UTF8);
return html.Replace("</body>", script + "\n</body>", StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
Log.LogStep($"PresentationTool.InjectFixContentSizeScript: asset not injected: {ex.Message}");
return html;
}
}
/// <summary>Injects the animated background block (assets/<style>.bg) before </head> so the
/// deck body gets a style-matching animated background. The block is looked up by style name
/// (case-insensitive); when the current style has no block, a random one is picked. Missing
/// assets or a missing </head> leave the HTML unchanged.</summary>
internal static string InjectAnimatedBackground(string html, string style)
{
string[] candidates;
try { candidates = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "assets"), "*.bg"); }
catch { return html; }
if (candidates.Length == 0) return html;
var file = candidates.FirstOrDefault(f =>
Path.GetFileNameWithoutExtension(f).Equals(style, StringComparison.OrdinalIgnoreCase))
?? candidates[Random.Shared.Next(candidates.Length)];
string block;
try { block = File.ReadAllText(file, Encoding.UTF8); }
catch { return html; }
if (html.Contains("</head>", StringComparison.OrdinalIgnoreCase))
{
// add 75% of transparency
var addTransparent = "<style>.slide {background-color: color-mix(in srgb, var(--color-slide-bg) 75%, transparent) !important;}</style>";
html = html.Replace("</head>", addTransparent + "\n</head>", StringComparison.OrdinalIgnoreCase);
}
return html.Contains("</head>", StringComparison.OrdinalIgnoreCase)
? html.Replace("</head>", block + "\n</head>", StringComparison.OrdinalIgnoreCase)
: html;
}
private static string? MimeFor(string path) =>
Path.GetExtension(path).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".bmp" => "image/bmp",
".svg" => "image/svg+xml",
".webp" => "image/webp",
_ => null
};
private static readonly HashSet<HtmlParseErrorCode> CriticalErrors = new()
{
HtmlParseErrorCode.TagNotClosed, HtmlParseErrorCode.TagNotOpened,
HtmlParseErrorCode.EndTagNotRequired, HtmlParseErrorCode.EndTagInvalidHere
};
private static bool IsValidHtml5(string html, out List<string> errors)
{
errors = new List<string>();
if (string.IsNullOrWhiteSpace(html))
{
errors.Add("HTML is empty.");
return false;
}
var doc = new HtmlDocument();
doc.LoadHtml(html);
foreach (var e in doc.ParseErrors)
{
if (CriticalErrors.Contains(e.Code))
errors.Add($"Line {e.Line}, Pos {e.LinePosition}: {e.Reason}");
}
return errors.Count == 0;
}
// ---------- JSON ----------
private static T? TryParseJson<T>(string raw) where T : class
{
var start = raw.IndexOf('{');
var end = raw.LastIndexOf('}');
if (start < 0 || end <= start) return null;
try { return JsonSerializer.Deserialize<T>(raw.Substring(start, end - start + 1), JsonOpts); }
catch (JsonException) { return null; }
}
private sealed class SufficiencyOpinion
{
public bool Sufficient { get; set; }
public bool DescriptionClear { get; set; }
public List<string>? Explanation { get; set; }
}
private sealed class ChangeVerdict
{
public bool Clear { get; set; }
public List<string>? Explanation { get; set; }
}
// ---------- File helpers ----------
private static string ReadTextCapped(string path, int maxChars)
{
var text = File.ReadAllText(path);
return text.Length <= maxChars ? text : text[..maxChars] + "\n…[truncated]";
}
private static string Truncate(string value, int max) =>
value.Length <= max ? value : value[..max] + "…";
// ---------- Deck template (embedded resource: Assets/template.html) ----------
private static readonly Lazy<string> TemplateHtmlLazy = new(() =>
{
var assembly = typeof(PresentationTool).Assembly;
var name = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith(".template.html", StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException("Embedded resource 'Assets/template.html' not found in PresentationTool.");
using var stream = assembly.GetManifestResourceStream(name);
if (stream == null)
throw new InvalidOperationException("Embedded resource 'Assets/template.html' not found in PresentationTool.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}, LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>Deck template loaded from the embedded resource (Assets/template.html), so the
/// HTML source of truth lives in a real file and ships inside the plugin assembly.</summary>
internal static string TemplateHtml => TemplateHtmlLazy.Value;
}
}