mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-05 04:07:21 +00:00
Added PowerPoint templates to the Slide Assistant (#1055)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
8c5cfa78b6
commit
6eb23ebbcb
136 files changed
+16535
-522
No files matched your search
@@ -0,0 +1,30 @@
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// The occasion of a presentation, which shapes how the LLM structures it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The list covers the common occasions only; OTHER takes any other one as text.
|
||||
/// </remarks>
|
||||
public enum PresentationOccasion
|
||||
{
|
||||
UNSPECIFIED = 0,
|
||||
|
||||
TALK,
|
||||
KEYNOTE,
|
||||
CONFERENCE_TALK,
|
||||
LECTURE,
|
||||
TRAINING,
|
||||
WORKSHOP,
|
||||
MULTI_DAY_WORKSHOP,
|
||||
THESIS_DEFENSE,
|
||||
RESEARCH_RESULTS,
|
||||
PROJECT_REPORT,
|
||||
MANAGEMENT_REPORT,
|
||||
PITCH,
|
||||
PRODUCT_PRESENTATION,
|
||||
KICK_OFF,
|
||||
TEAM_MEETING,
|
||||
|
||||
OTHER,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
|
||||
public static class PresentationOccasionExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(PresentationOccasionExtensions).Namespace, nameof(PresentationOccasionExtensions));
|
||||
|
||||
public static string Name(this PresentationOccasion occasion) => occasion switch
|
||||
{
|
||||
PresentationOccasion.UNSPECIFIED => TB("Unspecified occasion"),
|
||||
PresentationOccasion.TALK => TB("Talk"),
|
||||
PresentationOccasion.KEYNOTE => TB("Keynote"),
|
||||
PresentationOccasion.CONFERENCE_TALK => TB("Conference talk"),
|
||||
PresentationOccasion.LECTURE => TB("Lecture"),
|
||||
PresentationOccasion.TRAINING => TB("Training"),
|
||||
PresentationOccasion.WORKSHOP => TB("Workshop of one day"),
|
||||
PresentationOccasion.MULTI_DAY_WORKSHOP => TB("Workshop of several days"),
|
||||
PresentationOccasion.THESIS_DEFENSE => TB("Defense of a thesis or dissertation"),
|
||||
PresentationOccasion.RESEARCH_RESULTS => TB("Presentation of research results"),
|
||||
PresentationOccasion.PROJECT_REPORT => TB("Project or status report"),
|
||||
PresentationOccasion.MANAGEMENT_REPORT => TB("Report to management"),
|
||||
PresentationOccasion.PITCH => TB("Pitch"),
|
||||
PresentationOccasion.PRODUCT_PRESENTATION => TB("Product presentation"),
|
||||
PresentationOccasion.KICK_OFF => TB("Kick-off"),
|
||||
PresentationOccasion.TEAM_MEETING => TB("Team meeting"),
|
||||
PresentationOccasion.OTHER => TB("Other occasion"),
|
||||
|
||||
_ => TB("Unspecified occasion"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tells the model what the occasion means for the structure of the presentation.
|
||||
/// </summary>
|
||||
/// <param name="occasion">The occasion.</param>
|
||||
/// <param name="customOccasion">The occasion the user described, for OTHER.</param>
|
||||
public static string Prompt(this PresentationOccasion occasion, string customOccasion) => occasion switch
|
||||
{
|
||||
PresentationOccasion.TALK => "The presentation accompanies a talk. Structure it for a speaker who talks the audience through it.",
|
||||
PresentationOccasion.KEYNOTE => "The presentation accompanies a keynote. Favor few words per slide, strong key messages, and a story which carries the audience.",
|
||||
PresentationOccasion.CONFERENCE_TALK => "The presentation accompanies a talk at a conference. Keep to the essentials of a short time slot, and end with the key messages and room for questions.",
|
||||
PresentationOccasion.LECTURE => "The presentation accompanies a lecture. Teach step by step, with examples, and where it suits, end chapters with an interim summary or with room for questions.",
|
||||
PresentationOccasion.TRAINING => "The presentation accompanies a training. Teach practical skills step by step, with examples and exercises where the content offers them.",
|
||||
PresentationOccasion.WORKSHOP => "The presentation accompanies a workshop of one day. Plan for interaction, e.g., exercises, group work, or discussions, and for the breaks the schedule of the content has.",
|
||||
PresentationOccasion.MULTI_DAY_WORKSHOP => "The presentation accompanies a workshop of several days. Plan for interaction, mark where a day ends and the next one starts, and look back at the start of each day, following the schedule of the content.",
|
||||
PresentationOccasion.THESIS_DEFENSE => "The presentation defends a thesis or a dissertation before a committee. Present the research questions, the method, the results, and the contribution precisely, and anticipate critical questions.",
|
||||
PresentationOccasion.RESEARCH_RESULTS => "The presentation presents research results. Present the questions, the method, the results, and their meaning precisely and comprehensibly.",
|
||||
PresentationOccasion.PROJECT_REPORT => "The presentation reports on a project. Present the status, the progress, the problems, and the next steps clearly.",
|
||||
PresentationOccasion.MANAGEMENT_REPORT => "The presentation reports to management and may prepare a decision. Lead with the key messages, keep details short, and state clearly what is to be decided.",
|
||||
PresentationOccasion.PITCH => "The presentation pitches an idea, a product, or an offer. Lead with the benefit for the audience, and end with a clear call to action.",
|
||||
PresentationOccasion.PRODUCT_PRESENTATION => "The presentation presents a product. Show what it does and what it brings the audience, with examples.",
|
||||
PresentationOccasion.KICK_OFF => "The presentation opens a project at its kick-off. Present the goals, the team, the plan, and how the work is organized.",
|
||||
PresentationOccasion.TEAM_MEETING => "The presentation serves a team meeting. Keep it short and practical, with clear points and next steps.",
|
||||
PresentationOccasion.OTHER => $"The presentation is for this occasion: {customOccasion}. Structure it accordingly.",
|
||||
|
||||
_ => "No particular occasion is given. Structure the presentation by its content.",
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
@attribute [Route(Routes.ASSISTANT_SLIDE_BUILDER)]
|
||||
@using AIStudio.Settings.DataModel
|
||||
@using AIStudio.Tools.PowerPoint
|
||||
@using AIStudio.Tools.Rust
|
||||
@inherits AssistantBaseCore<AIStudio.Dialogs.Settings.SettingsDialogSlideBuilder>
|
||||
|
||||
<MudText Typo="Typo.h5" Class="mb-1 mt-3"> @T("Content to derive slide from")</MudText>
|
||||
@@ -7,8 +10,43 @@
|
||||
</MudJustifiedText>
|
||||
<MudTextField T="string" @bind-Text="@this.inputContent" Validation="@this.ValidatingContext" Adornment="Adornment.Start" Lines="6" MaxLines="12" AutoGrow="@false" Label="@T("Text content")" Variant="Variant.Outlined" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mb-1 mt-1"> @T("Attach documents")</MudText>
|
||||
<AttachDocuments Name="Documents for input" @bind-DocumentPaths="@this.loadedDocumentPaths" OnChange="@this.OnDocumentsChanged" CatchAllDocuments="true" UseSmallForm="false" Provider="@this.ProviderSettings"/>
|
||||
@* Side by side where the window is wide enough; each column ends with its zone, so both zones line up below explanations of different length: *@
|
||||
<MudGrid Class="mt-1">
|
||||
<MudItem xs="12" lg="6" Class="d-flex flex-column">
|
||||
<MudText Typo="Typo.h6" Class="mb-1"> @T("Attach documents")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2 flex-grow-1">
|
||||
@T("Documents and images attached here are source material: the LLM reads them, but puts none of them on a slide.")
|
||||
</MudJustifiedText>
|
||||
<AttachDocuments Name="Documents for input" @bind-DocumentPaths="@this.loadedDocumentPaths" OnChange="@this.OnDocumentsChanged" CatchAllDocuments="true" UseSmallForm="false" Provider="@this.ProviderSettings"/>
|
||||
</MudItem>
|
||||
<MudItem xs="12" lg="6" Class="d-flex flex-column">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">@T("Pictures for the slides")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2 flex-grow-1">
|
||||
@T("Attach the pictures which the presentation must show, e.g., photos, diagrams, or screenshots. Each of them goes on exactly one slide with room for a picture. Only PNG and JPEG pictures are possible. An LLM which can read images sees them; any other LLM knows only their file names, so clear names help.")
|
||||
</MudJustifiedText>
|
||||
<AttachDocuments Name="Pictures for the slides" @bind-DocumentPaths="@this.slidePictures" OnChange="@this.OnSlidePicturesChanged" CatchAllDocuments="false" UseSmallForm="false" AllowedFileTypes="@([FileTypes.SLIDE_PICTURE])" Provider="@this.ProviderSettings"/>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Picture library")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
|
||||
@SlideAssistantTexts.WhatAPictureLibraryOffers
|
||||
</MudJustifiedText>
|
||||
<SelectDirectory Directory="@this.pictureLibraryPath" DirectoryChanged="@this.OnPictureLibraryChanged" Label="@T("(Optional) Picture library")" DirectoryDialogTitle="@T("Select a picture library")" Validation="@this.ValidatePictureLibrary" Disabled="@this.IsPictureLibraryLocked" EnableDragDrop="true"/>
|
||||
@if (this.IsLockedPictureLibraryUnavailable)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">@T("The picture library of your organization is not available right now, e.g., because its network drive cannot be reached. Until it is, the assistant plans the slides without it.")</MudAlert>
|
||||
}
|
||||
@if (this.PictureLibraryHint is { Length: > 0 } pictureLibraryHint)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mb-3">@pictureLibraryHint</MudText>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.pictureLibraryPath) && !this.IsPictureLibraryLocked)
|
||||
{
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FolderOff" Variant="Variant.Outlined" OnClick="@this.UseNoPictureLibrary" Class="mb-3">
|
||||
@T("Use no picture library")
|
||||
</MudButton>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.h5" Class="mb-3 mt-6"> @T("Details about the desired presentation")</MudText>
|
||||
|
||||
@@ -22,11 +60,11 @@
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
|
||||
@T("You might want to specify important aspects that the LLM should consider when creating the slides. For example, the use of emojis or specific topics that should be highlighted.")
|
||||
</MudJustifiedText>
|
||||
<MudTextField T="string" AutoGrow="true" Lines="3" @bind-Text="@this.importantAspects" class="mb-1" Label="@T("(Optional) Important Aspects")" HelperText="@T("(Optional) Specify aspects that the LLM should consider when creating the slides. For example, the use of emojis or specific topics that should be highlighted.")" ShrinkLabel="true" Variant="Variant.Outlined" AdornmentIcon="@Icons.Material.Filled.List" Adornment="Adornment.Start" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
|
||||
<MudTextField T="string" AutoGrow="true" Lines="3" @bind-Text="@this.importantAspects" class="mb-1" Label="@T("(Optional) Important Aspects")" ShrinkLabel="true" Variant="Variant.Outlined" AdornmentIcon="@Icons.Material.Filled.List" Adornment="Adornment.Start" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Extent of the planned presentation")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
|
||||
@T("Please specify the extent of the planned presentation. This can be the number of slides, the number of bullet points per slide, or the time specification for the presentation. This will help the LLM to create a presentation that fits your needs. Leave the default values if you don't have specific requirements regarding the extent of the presentation. You might only want to specify one of these parameters, for example the time specification, and leave the others at their default values.")
|
||||
@T("Please specify the extent of the planned presentation. This can be the number of slides, the maximum number of bullet points per slide, or the time specification for the presentation. This will help the LLM to create a presentation that fits your needs. Leave the default values if you don't have specific requirements regarding the extent of the presentation. You might only want to specify one of these parameters, for example the time specification, and leave the others at their default values.")
|
||||
</MudJustifiedText>
|
||||
<MudGrid>
|
||||
<MudItem xs="4">
|
||||
@@ -39,10 +77,11 @@
|
||||
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="@this.numberOfBulletPoints"
|
||||
Label="@T("Number of bullet points")"
|
||||
Label="@T("Maximum number of bullet points per slide")"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="7"
|
||||
Max="@DataSlideBuilder.MAX_BULLET_POINTS_PER_SLIDE"
|
||||
Disabled="@this.IsBulletPointMaximumLocked"
|
||||
Class="mb-3" />
|
||||
</MudItem>
|
||||
|
||||
@@ -58,6 +97,12 @@
|
||||
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Language")</MudText>
|
||||
<EnumSelection T="CommonLanguages" NameFunc="@(language => language.Name())" @bind-Value="@this.selectedTargetLanguage" Icon="@Icons.Material.Filled.Translate" Label="@T("Target language")" AllowOther="@true" @bind-OtherInput="@this.customTargetLanguage" OtherValue="CommonLanguages.OTHER" LabelOther="@T("Custom target language")" ValidateOther="@this.ValidateCustomLanguage" />
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Occasion")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
|
||||
@T("The occasion helps the LLM to structure the presentation. A lecture, for example, may close its chapters with an interim summary or with room for questions, and a report to management leads with the key messages.")
|
||||
</MudJustifiedText>
|
||||
<EnumSelection T="PresentationOccasion" NameFunc="@(occasion => occasion.Name())" @bind-Value="@this.selectedOccasion" Icon="@Icons.Material.Filled.Event" Label="@T("Occasion of the presentation")" AllowOther="@true" @bind-OtherInput="@this.customOccasion" OtherValue="PresentationOccasion.OTHER" LabelOther="@T("Other occasion")" ValidateOther="@this.ValidateCustomOccasion" />
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Audience")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
|
||||
@T("Please specify the audience for the planned presentation. This will help the LLM to create a presentation that fits your needs. You can specify the audience profile, the age group, organizational level, and the expertise. You don't have to specify all of these parameters, for example you might only want to specify the audience profile and leave the other parameters at their default values.")
|
||||
@@ -66,4 +111,77 @@
|
||||
<EnumSelection T="AudienceAgeGroup" NameFunc="@(ageGroup => ageGroup.Name())" @bind-Value="@this.selectedAudienceAgeGroup" Icon="@Icons.Material.Filled.Cake" Label="@T("Audience age group")" />
|
||||
<EnumSelection T="AudienceOrganizationalLevel" NameFunc="@(level => level.Name())" @bind-Value="@this.selectedAudienceOrganizationalLevel" Icon="@Icons.Material.Filled.AccountTree" Label="@T("Audience organizational level")" />
|
||||
<EnumSelection T="AudienceExpertise" NameFunc="@(expertise => expertise.Name())" @bind-Value="@this.selectedAudienceExpertise" Icon="@Icons.Material.Filled.School" Label="@T("Audience expertise")" />
|
||||
|
||||
<MudText Typo="Typo.h5" Class="mb-1 mt-6"> @T("PowerPoint template")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
|
||||
@T("Choose a PowerPoint presentation or template (.pptx, .potx) whose layouts the slides should use. The LLM then plans every slide for one of these layouts, and you can create a PowerPoint file (.pptx) in the design of your template, which other programs such as LibreOffice Impress open as well. OpenDocument presentations (.odp, .otp) cannot serve as a template yet. Without a template, you get an outline, which you can copy or export.")
|
||||
</MudJustifiedText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
|
||||
@SlideAssistantTexts.WhatTheLLMKnowsOfLayouts
|
||||
</MudJustifiedText>
|
||||
<SelectFile File="@this.templatePath" FileChanged="@this.OnTemplateChanged" Label="@T("PowerPoint template")" FileDialogTitle="@T("Select a PowerPoint template")" Filter="@([FileTypes.POWER_POINT_TEMPLATE])" Validation="@this.ValidateTemplate" Disabled="@this.IsTemplateLocked" EnableDragDrop="true"/>
|
||||
@if (this.IsLockedTemplateUnavailable)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">@T("The PowerPoint template of your organization is not available right now, e.g., because its network drive cannot be reached. Until it is, the assistant plans an outline only.")</MudAlert>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.templatePath) && !this.IsTemplateLocked)
|
||||
{
|
||||
<MudButton StartIcon="@Icons.Material.Filled.LayersClear" Variant="Variant.Outlined" OnClick="@this.UseNoTemplate" Class="mb-3">
|
||||
@T("Use no template")
|
||||
</MudButton>
|
||||
}
|
||||
|
||||
@* The select renders its items again on its own, e.g., while the form validates, when the template may be gone already: *@
|
||||
@if (this.template is { } loadedTemplate)
|
||||
{
|
||||
<CompactExpansionPanel HeaderText="@T("Advanced Options")" HeaderIcon="@Icons.Material.Filled.Tune" Class="mb-3 rounded" @bind-Expanded="@this.advancedOptionsExpanded">
|
||||
@if (loadedTemplate.VariantLabels.Count > 0)
|
||||
{
|
||||
<MudSelect T="string" @bind-Value="@this.variantLabel" Label="@T("Color variant")" HelperText="@T("Your template offers its layouts in several colors. Choose one for the presentation.")" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Palette" Class="mb-3">
|
||||
@foreach (var label in loadedTemplate.VariantLabels)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@label">@label</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
|
||||
<MudTextField T="string" @bind-Text="@this.footerText" Label="@T("(Optional) Footer")" HelperText="@T("Shown on every slide whose layout has a footer, e.g., your name, organization, and date.")" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.ShortText" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
|
||||
|
||||
<MudSwitch T="bool" @bind-Value="@this.keepEmptyPicturePlaceholders" Color="Color.Primary" Class="mx-1">
|
||||
@T("Keep empty picture placeholders")
|
||||
</MudSwitch>
|
||||
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
|
||||
@SlideAssistantTexts.WhatKeepingEmptyPicturePlaceholdersDoes
|
||||
</MudJustifiedText>
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Layouts for kinds of slides")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@SlideAssistantTexts.WhatALayoutChoiceDoes
|
||||
</MudJustifiedText>
|
||||
<LayoutChoicePanels IsChosen="@(purpose => this.LayoutNameOf(purpose).Length > 0)">
|
||||
<ChoiceContent Context="purpose">
|
||||
<MudSelect T="string" Value="@this.LayoutNameOf(purpose)" ValueChanged="@(name => this.SetLayoutName(purpose, name))" Disabled="@this.AreLayoutsLocked" Label="@purpose.Label()" HelperText="@purpose.Help()" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.ViewQuilt" Class="mb-3">
|
||||
<MudSelectItem T="string" Value="@string.Empty">@LayoutPurposeExtensions.NoPreference</MudSelectItem>
|
||||
@foreach (var layoutName in loadedTemplate.FamilyNames)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@layoutName">@layoutName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</ChoiceContent>
|
||||
</LayoutChoicePanels>
|
||||
|
||||
@if (loadedTemplate.Slides.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Slides of the template")</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@SlideAssistantTexts.WhatHappensToTemplateSlides
|
||||
</MudJustifiedText>
|
||||
@foreach (var templateSlide in loadedTemplate.Slides)
|
||||
{
|
||||
<EnumSelection T="TemplateSlideUse" NameFunc="@(use => use.Name())" Value="@this.TemplateSlideUseOf(templateSlide.Number)" ValueChanged="@(use => this.SetTemplateSlideUse(templateSlide.Number, use))" ValidateSelection="@(use => this.ValidateTemplateSlideUse(templateSlide, use))" Disabled="@this.AreTemplateSlideUsesLocked" Icon="@Icons.Material.Filled.Slideshow" Label="@templateSlide.Label()"/>
|
||||
}
|
||||
}
|
||||
</CompactExpansionPanel>
|
||||
}
|
||||
|
||||
<ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/>
|
||||
@@ -0,0 +1,257 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// The part of the Slide Assistant which offers the LLM a folder of pictures and icons to choose from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The LLM reads only the names of the pictures, never the pictures themselves, and need not use
|
||||
/// any of them; see PictureLibrary. The library is read anew for each planning, so that a large
|
||||
/// one offers another random sample each time, and once whenever the user chooses a folder, to tell
|
||||
/// how many pictures it holds.
|
||||
/// </remarks>
|
||||
public partial class SlideAssistant
|
||||
{
|
||||
[Inject]
|
||||
private ThreadSafeRandom RandomSource { get; init; } = null!;
|
||||
|
||||
private string pictureLibraryPath = string.Empty;
|
||||
private static readonly AssistantSessionStateKey<string> PICTURE_LIBRARY_PATH_STATE_KEY = new(nameof(pictureLibraryPath));
|
||||
|
||||
/// <summary>
|
||||
/// How many pictures the chosen library holds, for the hint below the field; it holds no sample.
|
||||
/// </summary>
|
||||
private PictureLibraryContent pictureLibraryOverview = PictureLibraryContent.NONE;
|
||||
|
||||
private bool isReadingPictureLibrary;
|
||||
private CancellationTokenSource? pictureLibraryReading;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chosen library folder exists, as its last count found.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The form asks this with every rendering, and an unreachable network drive takes seconds to
|
||||
/// answer, so the folder is checked only when it changes, and away from the rendering.
|
||||
/// </remarks>
|
||||
private bool isPictureLibraryAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// What the library offers to the planning under way, while the system prompt is written for it.
|
||||
/// </summary>
|
||||
private PictureLibraryContent planningLibrary = PictureLibraryContent.NONE;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the organization dictates the picture library.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Like the template, the library is a preselection, so a lock binds only while preselection is enabled.
|
||||
/// </remarks>
|
||||
private bool IsPictureLibraryLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedPictureLibraryPath, out var meta) && meta.IsLocked;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the picture library of the organization is locked, but cannot be read right now, e.g.,
|
||||
/// because its network drive cannot be reached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The user cannot choose another folder then, so it must not block the form. The assistant plans
|
||||
/// without a library instead, and the form says why.
|
||||
/// </remarks>
|
||||
private bool IsLockedPictureLibraryUnavailable => this.IsPictureLibraryLocked && this.pictureLibraryPath.Length > 0 && !this.isReadingPictureLibrary && !this.isPictureLibraryAvailable;
|
||||
|
||||
private async Task OnPictureLibraryChanged(string path)
|
||||
{
|
||||
this.pictureLibraryPath = path;
|
||||
await this.ReadPictureLibraryOverviewAsync();
|
||||
if (this.Form is not null)
|
||||
await this.Form.Validate();
|
||||
}
|
||||
|
||||
private async Task UseNoPictureLibrary() => await this.OnPictureLibraryChanged(string.Empty);
|
||||
|
||||
private string? ValidatePictureLibrary(string path)
|
||||
{
|
||||
// The warning below the field explains a locked folder, since the user cannot change it, and
|
||||
// while the folder is still counted, it is too early to tell:
|
||||
if (string.IsNullOrWhiteSpace(path) || this.IsPictureLibraryLocked || this.isReadingPictureLibrary)
|
||||
return null;
|
||||
|
||||
return this.isPictureLibraryAvailable ? null : T("The folder does not exist.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the pictures of the chosen library without waiting for it, e.g., after the library was preselected or restored.
|
||||
/// </summary>
|
||||
private void StartCountingPictureLibrary() => this.ReadPictureLibraryOverviewAsync().Observe($"{nameof(SlideAssistant)}: counting the pictures of the library");
|
||||
|
||||
/// <summary>
|
||||
/// Counts the pictures of the chosen library for the hint, and cancels a count still running for another folder.
|
||||
/// </summary>
|
||||
private async Task ReadPictureLibraryOverviewAsync()
|
||||
{
|
||||
if (this.pictureLibraryReading is { } previousReading)
|
||||
{
|
||||
await previousReading.CancelAsync();
|
||||
previousReading.Dispose();
|
||||
}
|
||||
|
||||
this.pictureLibraryOverview = PictureLibraryContent.NONE;
|
||||
this.isPictureLibraryAvailable = false;
|
||||
var path = this.pictureLibraryPath;
|
||||
if (path.Length == 0)
|
||||
{
|
||||
this.pictureLibraryReading = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var reading = new CancellationTokenSource();
|
||||
this.pictureLibraryReading = reading;
|
||||
this.isReadingPictureLibrary = true;
|
||||
this.StateHasChanged();
|
||||
try
|
||||
{
|
||||
var isAvailable = await FolderExistsAsync(path, reading.Token);
|
||||
var overview = isAvailable ? await PictureLibrary.ReadAsync(path, this.RandomSource, 0, this.ReportInaccessibleLibraryFolder, reading.Token) : PictureLibraryContent.NONE;
|
||||
if (!reading.IsCancellationRequested)
|
||||
{
|
||||
this.isPictureLibraryAvailable = isAvailable;
|
||||
this.pictureLibraryOverview = overview;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Another folder was chosen meanwhile, and its count is under way.
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "The picture library '{FolderPath}' cannot be read.", path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A count which started without a user's action, e.g., for a preselected folder, ends
|
||||
// without a rendering of its own:
|
||||
if (ReferenceEquals(this.pictureLibraryReading, reading))
|
||||
{
|
||||
this.isReadingPictureLibrary = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the library for a planning, with a new random sample when it is large.
|
||||
/// </summary>
|
||||
/// <returns>What the library offers; nothing when the user chose no library or it cannot be read.</returns>
|
||||
private async Task<PictureLibraryContent> ReadPictureLibraryForPlanningAsync()
|
||||
{
|
||||
var path = this.pictureLibraryPath;
|
||||
if (path.Length == 0)
|
||||
return PictureLibraryContent.NONE;
|
||||
|
||||
var token = this.CancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
try
|
||||
{
|
||||
if (!await FolderExistsAsync(path, token))
|
||||
{
|
||||
this.Logger.LogWarning("The picture library '{FolderPath}' is not available, so the slides are planned without it.", path);
|
||||
return PictureLibraryContent.NONE;
|
||||
}
|
||||
|
||||
return await PictureLibrary.ReadAsync(path, this.RandomSource, PictureLibrary.MAX_LIBRARY_PICTURES, this.ReportInaccessibleLibraryFolder, token);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
this.Logger.LogWarning(e, "The picture library '{FolderPath}' cannot be read, so the slides are planned without it.", path);
|
||||
return PictureLibraryContent.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a folder exists, asked on a thread of its own, since an unreachable network drive takes seconds to answer.
|
||||
/// </summary>
|
||||
private static Task<bool> FolderExistsAsync(string path, CancellationToken token) => Task.Run(() => Directory.Exists(path), token);
|
||||
|
||||
private void ReportInaccessibleLibraryFolder(string folderPath, Exception e) => this.Logger.LogWarning(e, "The folder '{FolderPath}' of the picture library cannot be read.", folderPath);
|
||||
|
||||
/// <summary>
|
||||
/// What the chosen library holds, below the field; empty while there is nothing to tell.
|
||||
/// </summary>
|
||||
private string PictureLibraryHint
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.isReadingPictureLibrary)
|
||||
return T("Counting the pictures of the folder...");
|
||||
|
||||
if (!this.isPictureLibraryAvailable)
|
||||
return string.Empty;
|
||||
|
||||
var library = this.pictureLibraryOverview;
|
||||
var hints = new List<string>
|
||||
{
|
||||
library.PictureCount == 0
|
||||
? T("The folder holds no PNG or JPEG pictures.")
|
||||
: string.Format(T("{0} pictures, {1} of them icons."), library.PictureCount.CompactCount(), library.IconCount.CompactCount()),
|
||||
};
|
||||
|
||||
if (library.SkippedFileCount > 0)
|
||||
hints.Add(string.Format(T("{0} other files are left out, because they are neither PNG nor JPEG."), library.SkippedFileCount.CompactCount()));
|
||||
|
||||
if (library.PictureCount > PictureLibrary.MAX_LIBRARY_PICTURES)
|
||||
hints.Add(string.Format(T("For each planning, the LLM gets a random {0} of them."), SlideAssistantTexts.MaxLibraryPictures));
|
||||
|
||||
return string.Join(" ", hints);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pictures of the library by their numbers, which follow those of the pictures for the slides, for the user's request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The names come from a folder, which may be a network drive anybody can write to, so they pass
|
||||
/// the prompt injection filter like the texts of a template.
|
||||
/// </remarks>
|
||||
/// <param name="guardService">The prompt injection filter.</param>
|
||||
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
|
||||
/// <param name="library">What the library offers for this request.</param>
|
||||
/// <param name="firstNumber">The number of the first picture of the library.</param>
|
||||
/// <param name="folderPath">The library folder.</param>
|
||||
/// <returns>The section of the request; empty without pictures in the library.</returns>
|
||||
private static async Task<string> PromptPictureLibraryAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PictureLibraryContent library, int firstNumber, string folderPath)
|
||||
{
|
||||
if (library.Pictures.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var list = string.Join(Environment.NewLine, library.Pictures.Select((picture, index) => $"- Picture {firstNumber + index}: {picture.Name}{(picture.IsIcon ? " (icon)" : string.Empty)}"));
|
||||
var sanitizedList = await guardService.SanitizeAsync(list, PromptInjectionSource.FileContent(folderPath), promptInjectionScope);
|
||||
return $"""
|
||||
|
||||
# PICTURE_LIBRARY
|
||||
{sanitizedList}
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rules for the pictures of the library; empty without them.
|
||||
/// </summary>
|
||||
private string PromptPictureLibraryRules() => this.planningLibrary.Pictures.Count == 0
|
||||
? string.Empty
|
||||
: """
|
||||
- PICTURE_LIBRARY lists further pictures by their numbers and names; you know only their names. Fill the picture slots of your new slides from it: wherever a name fits the content of a slide, place that picture there by its number. Leave a picture slot empty only when no name fits. A picture which is not an icon appears at most once.
|
||||
- Pictures marked "(icon)" are icons. An icon may appear on several slides, e.g., to mark each risk the same way, and suits picture slots of any size, except those marked as backdrop.
|
||||
""";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.pictureLibraryReading?.Cancel();
|
||||
this.pictureLibraryReading?.Dispose();
|
||||
this.pictureLibraryReading = null;
|
||||
base.DisposeResources();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
using System.Globalization;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// The part of the Slide Assistant which works with PowerPoint: the template, the slides the model
|
||||
/// plans for its layouts, and the file written from them.
|
||||
/// </summary>
|
||||
public partial class SlideAssistant
|
||||
{
|
||||
/// <summary>
|
||||
/// The layouts of a plain presentation, which the model plans with when no template was chosen.
|
||||
/// </summary>
|
||||
private static readonly PresentationTemplate DEFAULT_CATALOG = PresentationTemplate.CreateDefault();
|
||||
|
||||
/// <summary>
|
||||
/// How many issues the repair request names at most.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A model handed fifty issues fixes the first few and loses track of the rest; a dozen names
|
||||
/// what is wrong in kind, and the model finds the like on its own.
|
||||
/// </remarks>
|
||||
private const int MAX_REPORTED_ISSUES = 12;
|
||||
|
||||
private string templatePath = string.Empty;
|
||||
private string footerText = string.Empty;
|
||||
private bool keepEmptyPicturePlaceholders;
|
||||
private string? variantLabel;
|
||||
private PresentationTemplate? template;
|
||||
private SlideDeckSpec? slideDeck;
|
||||
private bool isWritingPresentation;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the options of the template are open: its color variant, the footer, and its slides.
|
||||
/// </summary>
|
||||
private bool advancedOptionsExpanded;
|
||||
|
||||
/// <summary>
|
||||
/// What the user chose for each slide of the template, by its number.
|
||||
/// </summary>
|
||||
private List<TemplateSlideUse> templateSlideUses = [];
|
||||
|
||||
/// <summary>
|
||||
/// The name of the layout the user chose for each kind of slide; a kind left to the LLM is missing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Names stay when the user chooses another template, so that they apply again once a template
|
||||
/// with these layouts is back. A name the current template does not have counts as no choice.
|
||||
/// </remarks>
|
||||
private readonly Dictionary<LayoutPurpose, string> layoutNames = new();
|
||||
|
||||
/// <summary>
|
||||
/// The answer the slide deck was read from, which the session keeps to restore the deck.
|
||||
/// </summary>
|
||||
private string slideDeckAnswer = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The template the slide deck was planned for; empty when it was planned without one.
|
||||
/// </summary>
|
||||
private string slideDeckTemplatePath = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The pictures the slide deck may use, by the numbers the model gave them: every picture for the
|
||||
/// slides, and those of the library which the deck uses, not the thousands it was offered.
|
||||
/// </summary>
|
||||
private readonly Dictionary<int, string> slideDeckPicturePaths = new();
|
||||
|
||||
/// <summary>
|
||||
/// The numbers of the pictures in slideDeckPicturePaths which are icons.
|
||||
/// </summary>
|
||||
private readonly HashSet<int> slideDeckIconNumbers = [];
|
||||
|
||||
/// <summary>
|
||||
/// What the slide deck was planned on: the layouts and template slides it uses, as the model read them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The template may change before the user saves the presentation, see SlideDeckSpecValidator.StillFits.
|
||||
/// </remarks>
|
||||
private readonly Dictionary<string, string> slideDeckBasis = new(StringComparer.Ordinal);
|
||||
|
||||
private static readonly AssistantSessionStateKey<string> TEMPLATE_PATH_STATE_KEY = new(nameof(templatePath));
|
||||
private static readonly AssistantSessionStateKey<string> FOOTER_TEXT_STATE_KEY = new(nameof(footerText));
|
||||
private static readonly AssistantSessionStateKey<bool> KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY = new(nameof(keepEmptyPicturePlaceholders));
|
||||
private static readonly AssistantSessionStateKey<string> VARIANT_LABEL_STATE_KEY = new(nameof(variantLabel));
|
||||
private static readonly AssistantSessionStateKey<bool> ADVANCED_OPTIONS_EXPANDED_STATE_KEY = new(nameof(advancedOptionsExpanded));
|
||||
private static readonly AssistantSessionStateKey<List<TemplateSlideUse>> TEMPLATE_SLIDE_USES_STATE_KEY = new(nameof(templateSlideUses));
|
||||
private static readonly AssistantSessionStateKey<Dictionary<LayoutPurpose, string>> LAYOUT_NAMES_STATE_KEY = new(nameof(layoutNames));
|
||||
private static readonly AssistantSessionStateKey<string> SLIDE_DECK_ANSWER_STATE_KEY = new(nameof(slideDeckAnswer));
|
||||
private static readonly AssistantSessionStateKey<string> SLIDE_DECK_TEMPLATE_PATH_STATE_KEY = new(nameof(slideDeckTemplatePath));
|
||||
private static readonly AssistantSessionStateKey<Dictionary<int, string>> SLIDE_DECK_PICTURE_PATHS_STATE_KEY = new(nameof(slideDeckPicturePaths));
|
||||
private static readonly AssistantSessionStateKey<HashSet<int>> SLIDE_DECK_ICON_NUMBERS_STATE_KEY = new(nameof(slideDeckIconNumbers));
|
||||
private static readonly AssistantSessionStateKey<Dictionary<string, string>> SLIDE_DECK_BASIS_STATE_KEY = new(nameof(slideDeckBasis));
|
||||
|
||||
/// <summary>
|
||||
/// The layouts the model plans with: those of the chosen template, or those of a plain presentation.
|
||||
/// </summary>
|
||||
private PresentationTemplate Catalog => this.template ?? DEFAULT_CATALOG;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the organization dictates the template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The template is a preselection, so a lock binds only while preselection is enabled. Without
|
||||
/// preselection, the assistant starts without a template, and the user may choose one.
|
||||
/// </remarks>
|
||||
private bool IsTemplateLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.TemplatePath, out var meta) && meta.IsLocked;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the template of the organization is locked, but cannot be read right now, e.g.,
|
||||
/// because its network drive cannot be reached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The user cannot choose another template then, so it must not block the form. The assistant
|
||||
/// plans an outline instead, as without a template, and the form says why.
|
||||
/// </remarks>
|
||||
private bool IsLockedTemplateUnavailable => this.IsTemplateLocked && !string.IsNullOrWhiteSpace(this.templatePath) && this.template is null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the organization locked the layouts for kinds of slides.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Like the template, the layouts are a preselection, so a lock binds only while preselection is enabled.
|
||||
/// </remarks>
|
||||
private bool AreLayoutsLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedLayouts, out var meta) && meta.IsLocked;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the organization locked what the presentation does with the slides of the template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The lock binds only for the preselected template, whose slides the choices number. For any
|
||||
/// other template, the user chooses freely.
|
||||
/// </remarks>
|
||||
private bool AreTemplateSlideUsesLocked => this.IsPreselectedTemplate && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedTemplateSlideUses, out var meta) && meta.IsLocked;
|
||||
|
||||
/// <summary>
|
||||
/// Whether preselection is enabled and the chosen template is the preselected one.
|
||||
/// </summary>
|
||||
private bool IsPreselectedTemplate => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && this.template is not null && string.Equals(this.templatePath, this.SettingsManager.ConfigurationData.SlideBuilder.TemplatePath, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// What the user chose for the slides of the template, as the model and the check of its answer see it; empty without a template.
|
||||
/// </summary>
|
||||
private IReadOnlyList<TemplateSlideUse> ChosenTemplateSlideUses => this.template is null ? [] : this.templateSlideUses.ToList();
|
||||
|
||||
private bool HasChosenTemplateSlides => this.template is not null && this.templateSlideUses.Any(use => use is not TemplateSlideUse.OMIT);
|
||||
|
||||
/// <summary>
|
||||
/// The layouts the user chose for kinds of slides, as the current template offers them; none without a template.
|
||||
/// </summary>
|
||||
private ChosenLayouts ChosenLayoutsOfTemplate => this.template is null ? ChosenLayouts.NONE : ChosenLayouts.Of(this.template, this.layoutNames);
|
||||
|
||||
/// <summary>
|
||||
/// How many slides the user asked for, or 0 when the model may choose.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A number of slides wins over a duration. A duration means one slide per one and a half
|
||||
/// minutes, as the form has always counted it.
|
||||
/// </remarks>
|
||||
private int ExpectedSlideCount => this.numberOfSheets > 0
|
||||
? this.numberOfSheets
|
||||
: (int)Math.Round(this.timeSpecification / 1.5);
|
||||
|
||||
private bool PlannedForCurrentTemplate => this.template is not null && this.slideDeckTemplatePath.Length > 0 && string.Equals(this.slideDeckTemplatePath, this.templatePath, StringComparison.Ordinal);
|
||||
|
||||
private bool CanCreatePresentation => this.slideDeck is not null && this.PlannedForCurrentTemplate && !this.isWritingPresentation && !this.IsProcessing;
|
||||
|
||||
private string CreatePresentationTooltip
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.slideDeck is null)
|
||||
return T("Please plan the slides first.");
|
||||
|
||||
if (this.template is null)
|
||||
return T("Please choose a PowerPoint template to create a file from the slides.");
|
||||
|
||||
if (!this.PlannedForCurrentTemplate)
|
||||
return T("The slides were planned for another template. Please plan them again.");
|
||||
|
||||
return T("Creates a PowerPoint file in the design of your template.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the layouts of the chosen template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A template which cannot be read leaves no template at all. The form then says why, and
|
||||
/// nothing is planned for layouts nobody can write.
|
||||
/// </remarks>
|
||||
private void LoadTemplate()
|
||||
{
|
||||
this.template = null;
|
||||
if (string.IsNullOrWhiteSpace(this.templatePath))
|
||||
return;
|
||||
|
||||
if (!File.Exists(this.templatePath))
|
||||
{
|
||||
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' does not exist.", this.templatePath);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
this.template = PresentationTemplate.ReadFile(this.templatePath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "The PowerPoint template '{TemplatePath}' cannot be read.", this.templatePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// A variant of another template means nothing for this one, nor do choices for its slides:
|
||||
if (this.variantLabel is null || !this.template.VariantLabels.Contains(this.variantLabel))
|
||||
this.variantLabel = this.template.VariantLabels.FirstOrDefault();
|
||||
|
||||
if (this.templateSlideUses.Count != this.template.Slides.Count)
|
||||
this.templateSlideUses = DefaultTemplateSlideUsesOf(this.template);
|
||||
}
|
||||
|
||||
private async Task OnTemplateChanged(string path)
|
||||
{
|
||||
this.templatePath = path;
|
||||
this.templateSlideUses = [];
|
||||
this.LoadTemplate();
|
||||
this.ApplyPreselectedTemplateSlideUses();
|
||||
if (this.Form is not null)
|
||||
await this.Form.Validate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the presentation does with the slides of a template unless the user says otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The presentation leaves every slide out. A template serves its design first, and the slides
|
||||
/// of a file are as often samples and hints as an earlier presentation: whoever wants one of them
|
||||
/// in the new presentation says so.
|
||||
/// </remarks>
|
||||
private static List<TemplateSlideUse> DefaultTemplateSlideUsesOf(PresentationTemplate template) => Enumerable.Repeat(TemplateSlideUse.OMIT, template.Slides.Count).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Applies what the settings preselect for the slides of the template, as long as it is the preselected template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The choices number the slides of the preselected template, so they mean nothing for another
|
||||
/// one. A choice which the template cannot follow, e.g., for a slide it lost since, is logged.
|
||||
/// </remarks>
|
||||
private void ApplyPreselectedTemplateSlideUses()
|
||||
{
|
||||
if (!this.IsPreselectedTemplate || this.template is null)
|
||||
return;
|
||||
|
||||
var choices = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTemplateSlideUses;
|
||||
this.templateSlideUses = choices.ToTemplateSlideUses(this.template);
|
||||
foreach (var (number, use) in choices)
|
||||
if (this.templateSlideUses.UseOf(number) != use)
|
||||
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' cannot follow the preselected use '{Use}' of its slide {Number}: it has no such slide, or the slide has no placeholders for content.", this.templatePath, use, number);
|
||||
}
|
||||
|
||||
private TemplateSlideUse TemplateSlideUseOf(int number) => this.templateSlideUses.UseOf(number);
|
||||
|
||||
private void SetTemplateSlideUse(int number, TemplateSlideUse use)
|
||||
{
|
||||
if (number >= 1 && number <= this.templateSlideUses.Count)
|
||||
this.templateSlideUses[number - 1] = use;
|
||||
}
|
||||
|
||||
private string? ValidateTemplateSlideUse(TemplateSlide slide, TemplateSlideUse use)
|
||||
{
|
||||
if (use is TemplateSlideUse.EDIT && !slide.IsEditable)
|
||||
return T("The LLM cannot edit this slide, because its layout has no placeholders for content. Please keep it unchanged or leave it out.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name of the layout chosen for a kind of slide, as the form shows it.
|
||||
/// </summary>
|
||||
/// <returns>The name, or empty when the LLM decides, which it also does when the template has no layout of this name.</returns>
|
||||
private string LayoutNameOf(LayoutPurpose purpose) => this.template is not null && this.layoutNames.TryGetValue(purpose, out var name) && this.template.FindFamilyByName(name) is not null ? name : string.Empty;
|
||||
|
||||
private void SetLayoutName(LayoutPurpose purpose, string name)
|
||||
{
|
||||
if (name.Length == 0)
|
||||
this.layoutNames.Remove(purpose);
|
||||
else
|
||||
this.layoutNames[purpose] = name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rules for the layouts the user chose; empty when the LLM decides every layout.
|
||||
/// </summary>
|
||||
private string PromptChosenLayouts() => this.ChosenLayoutsOfTemplate.ToPromptRules();
|
||||
|
||||
private async Task UseNoTemplate() => await this.OnTemplateChanged(string.Empty);
|
||||
|
||||
private string? ValidateTemplate(string path)
|
||||
{
|
||||
// The warning below the field explains this case, since the user cannot change the template:
|
||||
if (string.IsNullOrWhiteSpace(path) || this.IsLockedTemplateUnavailable)
|
||||
return null;
|
||||
|
||||
if (!File.Exists(path))
|
||||
return T("The template file does not exist.");
|
||||
|
||||
if (this.template is null)
|
||||
return T("The template cannot be read. Please choose a PowerPoint presentation or template (.pptx, .potx).");
|
||||
|
||||
if (this.template.Families.Count == 0)
|
||||
return T("The template has no layouts with room for content.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pictures for the slides, in the order the model numbers them.
|
||||
/// </summary>
|
||||
private List<FileAttachment> PicturesOfRequest() => this.slidePictures.Where(n => n is { Exists: true, IsImage: true }).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The images of the source material, which the model reads but never places.
|
||||
/// </summary>
|
||||
private List<FileAttachment> SourceImagesOfRequest() => this.loadedDocumentPaths.Where(n => n is { Exists: true, IsImage: true }).ToList();
|
||||
|
||||
private string PromptPictures()
|
||||
{
|
||||
var rules = new List<string>();
|
||||
if (this.PicturesOfRequest().Count > 0)
|
||||
rules.Add(" - PICTURES lists the pictures the presentation must show, by their numbers. Place each of them exactly once, by its number, on a slide with a picture slot whose content it fits.");
|
||||
|
||||
if (this.PromptPictureLibraryRules() is { Length: > 0 } libraryRules)
|
||||
rules.Add(libraryRules);
|
||||
|
||||
if (rules.Count == 0)
|
||||
rules.Add(" - No pictures for the slides are attached. Leave out \"pictures\" on every slide.");
|
||||
|
||||
if (this.SourceImagesOfRequest().Count > 0)
|
||||
rules.Add(" - Images attached as source material are for reading only. Never place them on a slide.");
|
||||
|
||||
return string.Join(Environment.NewLine, rules);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pictures for the slides by their numbers, for the user's request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The names are those of the user's files, so they go with the request, like the paths of the
|
||||
/// attached documents, rather than into the system prompt. A model which reads images gets the
|
||||
/// pictures as well; any other model has only their names to go by.
|
||||
/// </remarks>
|
||||
/// <param name="pictures">The pictures taken at the start of the request, in the order the model numbers them.</param>
|
||||
/// <param name="modelSeesPictures">Whether the pictures go to the model as images.</param>
|
||||
/// <returns>The section of the request; empty without pictures.</returns>
|
||||
private static string PromptPictureList(IReadOnlyList<FileAttachment> pictures, bool modelSeesPictures)
|
||||
{
|
||||
if (pictures.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var list = string.Join(Environment.NewLine, pictures.Select((picture, index) => $"- Picture {index + 1}: {picture.FileName}"));
|
||||
var seeing = modelSeesPictures
|
||||
? "The images attached to this request start with these pictures, in this order."
|
||||
: "You cannot see these pictures. Place them by what their names say.";
|
||||
|
||||
return $"""
|
||||
|
||||
# PICTURES
|
||||
{seeing}
|
||||
{list}
|
||||
""";
|
||||
}
|
||||
|
||||
private string PromptSlideCount()
|
||||
{
|
||||
var expectedSlideCount = this.ExpectedSlideCount;
|
||||
if (expectedSlideCount <= 0)
|
||||
return " - Choose as many slides as the content needs.";
|
||||
|
||||
return this.HasChosenTemplateSlides
|
||||
? $" - The presentation has exactly {expectedSlideCount} slides, the title slide and the template slides included."
|
||||
: $" - The presentation has exactly {expectedSlideCount} slides, the title slide included.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rules for the slides of the template the user chose; empty when the user chose none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The slides themselves come with the user's request, as TEMPLATE_SLIDES, since their texts are
|
||||
/// content of a file rather than instructions.
|
||||
/// </remarks>
|
||||
private string PromptTemplateSlides()
|
||||
{
|
||||
if (!this.HasChosenTemplateSlides)
|
||||
return string.Empty;
|
||||
|
||||
return """
|
||||
|
||||
# Template slides
|
||||
The user chose slides of the template to use in the presentation. TEMPLATE_SLIDES lists them in their order, each one either to keep unchanged or to edit.
|
||||
- Write a slide to keep as { "template_slide": <its number> } and nothing else. Add "starts_chapter": true where it opens a chapter.
|
||||
- Write a slide to edit with "template_slide": <its number> and its new content in the usual fields, which fill the slots its entry names. Leave out "layout": the slide keeps its own. A slot you neither fill nor remove keeps the content of the template slide, and so does every element you do not remove.
|
||||
- Where an entry gives a slot its place and size "on this slide", the slide moved or resized it, e.g., to make room for a picture of its own, or carries it as a copy of another slot, e.g., a further heading with its text. Fill such a slot like any other, and stay within the lines and characters stated there rather than those of the layout.
|
||||
- An entry to edit also lists the elements the slide carries besides its slots, e.g., pictures, text boxes, charts, or tables of its own, with their place and the slots they overlap. List what the slide no longer shows in "remove", by the names the entry uses, e.g., "remove": ["element 2", "label 1"].
|
||||
- Remove what does not fit the new content: samples and hints of the template, e.g., placeholder pictures, sample texts such as "Lorem ipsum", or a request to name the source of a picture; old content of a slot which no longer fits; and every element which overlaps a slot you fill, unless it covers most of the slide as a background. Keep what still belongs to the slide, e.g., a logo or a decoration. Never fill and remove the same slot, and never remove the title.
|
||||
- Use every listed slide exactly once, in the listed order. Add new slides before, between, and after them, wherever the content calls for it.
|
||||
- Do not repeat on a new slide what a kept slide says already.
|
||||
- Example: "slides": [{ "template_slide": 1 }, { "layout": "L3", "title": "..." }, { "template_slide": 3, "title": "...", "texts": [["..."]], "remove": ["element 1"] }]
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rule for the field listing what an edited template slide removes; empty when the user
|
||||
/// edits no template slide, so that the model does not hear of the field at all.
|
||||
/// </summary>
|
||||
private string PromptRemoveRule() => this.template is not null && this.templateSlideUses.Any(use => use is TemplateSlideUse.EDIT)
|
||||
? """
|
||||
- "remove" lists the slots and elements a template slide to edit no longer shows, by the names TEMPLATE_SLIDES uses. Leave it out on every other slide.
|
||||
"""
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The layouts the model plans with, for the user's request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The catalog names the layouts as the author of the template named them. These names come from
|
||||
/// a file, which may carry instructions of somebody else, so the catalog goes with the request
|
||||
/// rather than into the system prompt, and the layouts of a template pass the prompt injection
|
||||
/// filter like its slides. The layouts of a plain presentation come from the code.
|
||||
/// </remarks>
|
||||
/// <param name="guardService">The prompt injection filter.</param>
|
||||
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
|
||||
/// <param name="catalog">The layouts taken at the start of the request.</param>
|
||||
/// <param name="templatePath">The template the layouts were read from; empty for the layouts of a plain presentation.</param>
|
||||
/// <returns>The section of the request.</returns>
|
||||
private static async Task<string> PromptLayoutsAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PresentationTemplate catalog, string templatePath)
|
||||
{
|
||||
var layouts = catalog.ToCatalogPrompt();
|
||||
if (templatePath.Length > 0)
|
||||
layouts = await guardService.SanitizeAsync(layouts, PromptInjectionSource.FileContent(templatePath), promptInjectionScope);
|
||||
|
||||
return $"""
|
||||
|
||||
# LAYOUTS
|
||||
{layouts}
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The slides of the template the user chose, with what they hold, for the user's request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Their texts come from a file, which may carry instructions of somebody else, so they pass the
|
||||
/// prompt injection filter like every attached document.
|
||||
/// </remarks>
|
||||
/// <param name="guardService">The prompt injection filter.</param>
|
||||
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
|
||||
/// <param name="catalog">The layouts and slides taken at the start of the request.</param>
|
||||
/// <param name="templatePath">The template they were read from; empty without a template.</param>
|
||||
/// <param name="templateSlideUses">What the user chose for each slide of the template.</param>
|
||||
/// <returns>The section of the request; empty when the user chose no slide.</returns>
|
||||
private static async Task<string> PromptTemplateSlidesAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PresentationTemplate catalog, string templatePath, IReadOnlyList<TemplateSlideUse> templateSlideUses)
|
||||
{
|
||||
if (templatePath.Length == 0 || catalog.ToTemplateSlidesPrompt(templateSlideUses) is not { Length: > 0 } templateSlides)
|
||||
return string.Empty;
|
||||
|
||||
var sanitizedTemplateSlides = await guardService.SanitizeAsync(templateSlides, PromptInjectionSource.FileContent(templatePath), promptInjectionScope);
|
||||
return $"""
|
||||
|
||||
# TEMPLATE_SLIDES
|
||||
{sanitizedTemplateSlides}
|
||||
""";
|
||||
}
|
||||
|
||||
private string PromptBulletPoints()
|
||||
{
|
||||
var count = this.numberOfBulletPoints > 0
|
||||
? $" - A slide has at most {this.numberOfBulletPoints} main points, all its text slots together. Fewer are fine where the content needs fewer."
|
||||
: " - Choose between 1 and 7 main points per text slot, as its space allows.";
|
||||
|
||||
return $"""
|
||||
{count}
|
||||
- Every point is short: at most 12 words, clear, and directly related to the slide title.
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the slide deck out of an answer and checks it against the layouts it was planned for.
|
||||
/// </summary>
|
||||
/// <returns>The deck, or none when the answer cannot be read, with the issues, written for the model.</returns>
|
||||
private CheckedAnswer CheckAnswer(string answer, PresentationTemplate catalog, int pictureCount, LibraryPictures libraryPictures, IReadOnlyList<TemplateSlideUse> chosenTemplateSlideUses, ChosenLayouts chosenLayouts)
|
||||
{
|
||||
if (!SlideDeckSpec.TryParse(answer, out var deck, out var issue))
|
||||
return new CheckedAnswer(null, [issue]);
|
||||
|
||||
return new CheckedAnswer(deck, SlideDeckSpecValidator.Validate(deck, catalog, pictureCount, this.ExpectedSlideCount, chosenTemplateSlideUses, chosenLayouts, this.numberOfBulletPoints, libraryPictures));
|
||||
}
|
||||
|
||||
private static string RepairPromptOf(IReadOnlyList<string> issues)
|
||||
{
|
||||
var listedIssues = string.Join(Environment.NewLine, issues.Take(MAX_REPORTED_ISSUES).Select(issue => $"- {issue}"));
|
||||
return $"""
|
||||
Your JSON does not fit the layouts yet:
|
||||
{listedIssues}
|
||||
|
||||
Answer with the complete corrected JSON object and nothing else.
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the slide deck as an outline, which is the visible result of the assistant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The outline becomes the last block of the chat, so copying the result, exporting it, and
|
||||
/// sending it to a chat all work with the outline rather than with the JSON behind it.
|
||||
/// </remarks>
|
||||
private void ShowOutline(SlideDeckSpec deck, PresentationTemplate catalog, IReadOnlyDictionary<int, string> pictureNames)
|
||||
{
|
||||
var outline = new ContentBlock
|
||||
{
|
||||
Time = DateTimeOffset.Now,
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.AI,
|
||||
HideFromUser = false,
|
||||
Content = new ContentText { Text = deck.ToMarkdownOutline(catalog, pictureNames) },
|
||||
};
|
||||
|
||||
this.ChatThread?.Blocks.Add(outline);
|
||||
this.ResultingContentBlock = outline;
|
||||
}
|
||||
|
||||
private void RestoreSlideDeck() => this.slideDeck = this.slideDeckAnswer.Length > 0 && SlideDeckSpec.TryParse(this.slideDeckAnswer, out var deck, out _) ? deck : null;
|
||||
|
||||
private async Task CreatePresentationFileAsync()
|
||||
{
|
||||
if (!this.CanCreatePresentation || this.slideDeck is not { } deck)
|
||||
return;
|
||||
|
||||
var fileName = FileExportFormatExtensions.ToFileNameFragment(deck.Title);
|
||||
var response = await this.RustService.SaveFile(T("Save the presentation"), [FileTypes.POWER_POINT_PRESENTATION], $"{(fileName.Length == 0 ? "presentation" : fileName)}.pptx");
|
||||
if (response.UserCancelled)
|
||||
return;
|
||||
|
||||
var targetPath = response.SaveFilePath;
|
||||
this.isWritingPresentation = true;
|
||||
this.StateHasChanged();
|
||||
try
|
||||
{
|
||||
var options = new PresentationWriteOptions(this.variantLabel, this.footerText.Trim(), this.keepEmptyPicturePlaceholders);
|
||||
var pictures = new RustPictureSource(this.RustService, this.slideDeckPicturePaths, this.slideDeckIconNumbers);
|
||||
var result = await PresentationFile.CreateAsync(this.slideDeckTemplatePath, targetPath, deck, this.slideDeckBasis, options, pictures, this.Logger, CancellationToken.None);
|
||||
if (!result.IsWritten)
|
||||
{
|
||||
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' changed since the slides were planned, and they no longer fit it.", this.slideDeckTemplatePath);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The template has changed since the slides were planned. Please create the slides again.")));
|
||||
return;
|
||||
}
|
||||
|
||||
var placements = deck.Slides.SelectMany(slide => slide.Pictures).ToList();
|
||||
this.Logger.LogInformation("The slide assistant saved {SlideCount} slides to '{FilePath}', with {PlacedPictureCount} of {PictureCount} pictures placed and empty picture placeholders {EmptyPicturePlaceholders}.", deck.Slides.Count, targetPath, placements.Count(number => !result.SkippedPictures.Contains(number)), placements.Count, this.keepEmptyPicturePlaceholders ? "kept" : "left out");
|
||||
if (result.SkippedPictures.Count > 0)
|
||||
{
|
||||
var names = result.SkippedPictures.Select(number => this.slideDeckPicturePaths.TryGetValue(number, out var picturePath) ? Path.GetFileName(picturePath) : number.ToString(CultureInfo.InvariantCulture));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.HideImage, string.Format(T("These pictures could not be placed, because PowerPoint cannot show their format or they cannot be read: {0}"), string.Join(", ", names))));
|
||||
}
|
||||
|
||||
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Slideshow, T("The PowerPoint file was created.")));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogError(e, "Creating the PowerPoint file '{FilePath}' failed.", targetPath);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The PowerPoint file could not be created. Please check that the template can be read and that the target file is not open in another program.")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isWritingPresentation = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A slide deck read out of an answer of the model, with what is wrong with it.
|
||||
/// </summary>
|
||||
/// <param name="Deck">The deck; null when the answer cannot be read.</param>
|
||||
/// <param name="Issues">The issues, written for the model; empty when the deck fits.</param>
|
||||
private sealed record CheckedAnswer(SlideDeckSpec? Deck, IReadOnlyList<string> Issues);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// The part of the Slide Assistant which tells, below the progress bar, what the LLM is doing while
|
||||
/// it plans.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The answer is JSON, which the assistant does not show. Without these texts, the user would watch
|
||||
/// a bar for minutes without knowing whether anything happens: the LLM first thinks about the
|
||||
/// structure, which streams nothing at all, then writes the slides one by one, and may fit them to
|
||||
/// the layouts in a second round.
|
||||
/// </remarks>
|
||||
public partial class SlideAssistant
|
||||
{
|
||||
private PlanningStep planningStep;
|
||||
private static readonly AssistantSessionStateKey<PlanningStep> PLANNING_STEP_STATE_KEY = new(nameof(planningStep));
|
||||
|
||||
private ContentText? countedAnswer;
|
||||
private StreamedSlideCounter slideCounter = new();
|
||||
|
||||
protected override string ProgressText => this.planningStep switch
|
||||
{
|
||||
PlanningStep.PLANNING when this.SlidesSoFar() is var slides and > 0 => string.Format(T("The LLM is writing slide {0}..."), slides),
|
||||
PlanningStep.PLANNING => T("The LLM is planning the structure of the presentation..."),
|
||||
PlanningStep.REPAIRING => T("The LLM is fitting the slides to the layouts..."),
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Counts the slides which the answer streaming in has started so far.
|
||||
/// </summary>
|
||||
private int SlidesSoFar()
|
||||
{
|
||||
if (this.ResultingContentBlock?.Content is not ContentText answer)
|
||||
return 0;
|
||||
|
||||
if (!ReferenceEquals(answer, this.countedAnswer))
|
||||
{
|
||||
this.countedAnswer = answer;
|
||||
this.slideCounter = new();
|
||||
}
|
||||
|
||||
return this.slideCounter.Count(answer.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the LLM is asked to do right now.
|
||||
/// </summary>
|
||||
private enum PlanningStep
|
||||
{
|
||||
NONE,
|
||||
PLANNING,
|
||||
REPAIRING,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Text;
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Dialogs.Settings;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
@@ -10,52 +12,77 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
{
|
||||
protected override Tools.Components Component => Tools.Components.SLIDE_BUILDER_ASSISTANT;
|
||||
|
||||
protected override string Title => T("Slide Planner Assistant");
|
||||
protected override string Title => T("Slide Assistant");
|
||||
|
||||
protected override string Description => T("This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also specify the number of bullet points. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience.");
|
||||
protected override string Description => T("This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also limit the number of bullet points per slide. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience. When you choose a PowerPoint template, the slides use its layouts, and you can create a PowerPoint file in its design from the result.");
|
||||
|
||||
protected override string SystemPrompt =>
|
||||
$$$"""
|
||||
You are a professional presentation editor and writer.
|
||||
Create a clear, single-slide outline from the user's inputs.
|
||||
|
||||
Plan a presentation from the user's inputs and describe it slide by slide as JSON, using the slide layouts LAYOUTS lists.
|
||||
|
||||
# Presentation title:
|
||||
- IGNORE the language of the PRESENTATION_TITLE.
|
||||
- Translate PRESENTATION_TITLE in: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
|
||||
|
||||
# Content
|
||||
- You get the following inputs: PRESENTATION_TITLE, PRESENTATION_CONTENT, and any attached documents that may provide additional context or source material (DOCUMENTS).
|
||||
|
||||
{{{this.GetDocumentTaskDescription()}}}
|
||||
{{{this.PromptImportantAspects()}}}
|
||||
|
||||
# Subheadings
|
||||
- Rule for creating the individual subheadings:
|
||||
- If {{{this.numberOfSheets}}} is NOT 0
|
||||
- Generate exactly {{{this.numberOfSheets}}} precise subheadings, each heading represents one slide in a presentation.
|
||||
- If {{{this.timeSpecification}}} is NOT 0
|
||||
- Generate exactly {{{this.calculatedNumberOfSlides}}} precise subheadings, each heading represents one slide in a presentation.
|
||||
- If either parameter is 0, ignore that rules.
|
||||
- Each subheadings must have:
|
||||
- A clear, concise, and thematically meaningful heading.
|
||||
- Place *** on its own line immediately before each heading.
|
||||
|
||||
# Bullet points (per subheading)
|
||||
- You MUST generate exactly this {{{this.numberOfBulletPoints}}} many bullet points per subheading:
|
||||
- If {{{this.numberOfBulletPoints}}} == 0 → choose a number between 1 and 7 (your choice, but max 7).
|
||||
- Each bullet point must have:
|
||||
- Each bullet point must be max 12 words.
|
||||
- Clear and directly related to the subheading and summarizing the slide’s content.
|
||||
|
||||
# Output requirements:
|
||||
- Output only Markdown.
|
||||
- Start with a single H1 title that contains the user's PRESENTATION_TITLE.
|
||||
- Then add headings with own bullet lists based on the provided source material: PRESENTATION_CONTENT, DOCUMENTS, and attached images.
|
||||
# Content
|
||||
- You get the following inputs: PRESENTATION_TITLE, PRESENTATION_CONTENT, the layouts the slides use (LAYOUTS), any attached documents (DOCUMENTS) or images that provide source material, and the pictures for the slides (PICTURES).
|
||||
- Base the slides on the provided source material: PRESENTATION_CONTENT, DOCUMENTS, and attached images.
|
||||
- If both PRESENTATION_CONTENT and attached source material are provided, use all of them, while prioritizing direct user instructions from PRESENTATION_CONTENT when resolving ambiguity.
|
||||
- If PRESENTATION_CONTENT is empty but attached source material is available, create the slides from the attached source material.
|
||||
- If neither PRESENTATION_CONTENT nor any attached source material is available, output the title and one bullet: "No content provided."
|
||||
- If neither PRESENTATION_CONTENT nor any attached source material is available, create a single slide with the title and the text "No content provided."
|
||||
|
||||
{{{this.GetDocumentTaskDescription()}}}
|
||||
{{{this.PromptImportantAspects()}}}
|
||||
|
||||
# Layouts
|
||||
Every slide uses one of the layouts LAYOUTS lists. A layout offers slots, listed in reading order with their place on the slide and how much text fits.
|
||||
- Choose for each slide the layout whose slots suit its content, and vary the layouts where the content allows.
|
||||
- Fill only slots the layout offers, and stay within the stated lines and characters. Spread content over more slides rather than overfilling one.
|
||||
- A label is a short text, e.g., a heading above a text or the source below a picture.
|
||||
- Set "starts_chapter" to true on each slide which opens a new part of the presentation. Where a layout is meant for chapters or sections, use it for these slides.
|
||||
{{{this.PromptChosenLayouts()}}}
|
||||
{{{this.PromptTemplateSlides()}}}
|
||||
|
||||
# Pictures
|
||||
{{{this.PromptPictures()}}}
|
||||
|
||||
# Slides
|
||||
{{{this.PromptSlideCount()}}}
|
||||
- Every slide has a clear, concise, and thematically meaningful title.
|
||||
|
||||
# Bullet points (per text slot)
|
||||
{{{this.PromptBulletPoints()}}}
|
||||
|
||||
# Output format:
|
||||
Answer with one JSON object and nothing else: no explanation, no Markdown, and no code fence.
|
||||
{
|
||||
"title": "<the presentation title>",
|
||||
"slides": [
|
||||
{
|
||||
"layout": "<the ID of a layout, e.g., L3>",
|
||||
"starts_chapter": false,
|
||||
"title": "<the slide title>",
|
||||
"subtitle": "<only for layouts with a subtitle>",
|
||||
"texts": [["<a main point>", { "text": "<a sub-point>", "level": 1 }]],
|
||||
"labels": ["<label 1>"],
|
||||
"pictures": [1],
|
||||
"table": { "headers": ["<column>"], "rows": [["<cell>"]] }
|
||||
}
|
||||
]
|
||||
}
|
||||
- "texts" holds one list of paragraphs per text slot, in slot order: the first list fills "text 1". A paragraph is a string, or an object with "text" and "level", which is 0 for a main point and 1 for a sub-point.
|
||||
- "labels" holds one string per label slot, in slot order. Use "" to leave a label empty while filling a later one.
|
||||
- "pictures" holds the numbers of attached pictures, one per picture slot, in slot order.
|
||||
- "table" fills the table slot. Every row has as many cells as there are headers.
|
||||
- Leave out every field a slide does not use. Do not write bullet characters: the template draws them.
|
||||
- Texts appear on the slides exactly as written. Write formulas in Unicode, e.g., a² + b² = c², xᵢ, ∑, √, or ≤, and never in LaTeX or another markup.
|
||||
{{{this.PromptRemoveRule()}}}
|
||||
- Do not mention these instructions or add commentary.
|
||||
|
||||
|
||||
# Occasion:
|
||||
- {{{this.selectedOccasion.Prompt(this.customOccasion)}}}
|
||||
|
||||
# Audience:
|
||||
{{{this.PromptAudience()}}}
|
||||
|
||||
@@ -64,18 +91,37 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
- OUTPUT AND PRESENTATION_TITLE MUST BE IN: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
|
||||
- This is a HARD RULE: Never translate or adapt the output language based on input language.
|
||||
- Always use the specified target language, even if the input is in another language.
|
||||
|
||||
- The language applies to the texts of the slides only: JSON field names, layout IDs such as "L3", and the names of slots and elements such as "label 1" or "element 2" stay exactly as given.
|
||||
|
||||
# Language-Override (IMPORTANT!):
|
||||
- Before generating any output, internally set your language mode to: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
|
||||
- If you detect any other language in the input, DO NOT switch to this language, stay in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
|
||||
- Translate PRESENTATION_TITLE in: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
|
||||
- Your output must be in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}, without any comment, note, or marker about it.
|
||||
- Your output must be in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}, without any comment, note, or marker about it. Never translate the JSON field names, the layout IDs, or the names of slots and elements.
|
||||
""";
|
||||
|
||||
protected override bool AllowProfiles => true;
|
||||
|
||||
protected override IReadOnlyList<IButtonData> FooterButtons => [];
|
||||
|
||||
protected override IReadOnlyList<IButtonData> FooterButtons =>
|
||||
[
|
||||
new ButtonData(T("Create PowerPoint file"), Icons.Material.Filled.Slideshow, Color.Default, this.CreatePresentationTooltip, this.CreatePresentationFileAsync, () => !this.CanCreatePresentation),
|
||||
];
|
||||
|
||||
//
|
||||
// The model answers in JSON, which nobody wants to watch stream in. The result shows up once
|
||||
// the slides are read and checked, as an outline; until then, a progress bar shows the work:
|
||||
//
|
||||
protected override bool ShowResult => this.slideDeck is not null;
|
||||
|
||||
protected override bool ShowDedicatedProgress => true;
|
||||
|
||||
/// <summary>
|
||||
/// The slides may carry the date, e.g., on the title slide or in an imprint, so the model must not guess it.
|
||||
/// </summary>
|
||||
protected override bool IncludeDateTime => true;
|
||||
|
||||
protected override string ExportFileName => this.slideDeck is { Title.Length: > 0 } deck ? deck.Title : this.Title;
|
||||
|
||||
protected override string SubmitText => T("Create Slides");
|
||||
|
||||
protected override Func<Task> SubmitAction => this.CreateSlideBuilder;
|
||||
@@ -95,7 +141,7 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
return new ChatThread
|
||||
{
|
||||
ChatId = Guid.NewGuid(),
|
||||
Name = string.Format(T("{0} - Slide Builder Session"), this.inputTitle),
|
||||
Name = string.Format(T("{0} - Slide Assistant Session"), this.inputTitle),
|
||||
SystemPrompt = SystemPrompts.DEFAULT,
|
||||
Blocks =
|
||||
[
|
||||
@@ -108,8 +154,8 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
ContentType = ContentType.TEXT,
|
||||
Content = new ContentText
|
||||
{
|
||||
Text = this.T("The result of your previous slide builder session."),
|
||||
FileAttachments = this.loadedDocumentPaths.ToList(),
|
||||
Text = this.T("The result of your previous Slide Assistant session."),
|
||||
FileAttachments = [..this.loadedDocumentPaths, ..this.slidePictures],
|
||||
}
|
||||
},
|
||||
|
||||
@@ -157,27 +203,59 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
this.inputTitle = string.Empty;
|
||||
this.inputContent = string.Empty;
|
||||
this.loadedDocumentPaths.Clear();
|
||||
this.slidePictures.Clear();
|
||||
this.selectedAudienceProfile = AudienceProfile.UNSPECIFIED;
|
||||
this.selectedAudienceAgeGroup = AudienceAgeGroup.UNSPECIFIED;
|
||||
this.selectedAudienceOrganizationalLevel = AudienceOrganizationalLevel.UNSPECIFIED;
|
||||
this.selectedAudienceExpertise = AudienceExpertise.UNSPECIFIED;
|
||||
this.selectedOccasion = PresentationOccasion.UNSPECIFIED;
|
||||
this.customOccasion = string.Empty;
|
||||
this.slideDeck = null;
|
||||
this.slideDeckAnswer = string.Empty;
|
||||
this.slideDeckTemplatePath = string.Empty;
|
||||
this.slideDeckPicturePaths.Clear();
|
||||
this.slideDeckIconNumbers.Clear();
|
||||
this.slideDeckBasis.Clear();
|
||||
this.templateSlideUses = [];
|
||||
this.layoutNames.Clear();
|
||||
if (!this.MightPreselectValues())
|
||||
{
|
||||
this.selectedTargetLanguage = CommonLanguages.AS_IS;
|
||||
this.customTargetLanguage = string.Empty;
|
||||
this.footerText = string.Empty;
|
||||
this.keepEmptyPicturePlaceholders = false;
|
||||
this.numberOfBulletPoints = 0;
|
||||
this.pictureLibraryPath = string.Empty;
|
||||
this.StartCountingPictureLibrary();
|
||||
this.templatePath = string.Empty;
|
||||
this.LoadTemplate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected override bool MightPreselectValues()
|
||||
{
|
||||
if (this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)
|
||||
{
|
||||
this.templatePath = this.SettingsManager.ConfigurationData.SlideBuilder.TemplatePath;
|
||||
this.LoadTemplate();
|
||||
this.ApplyPreselectedTemplateSlideUses();
|
||||
this.footerText = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedFooterText;
|
||||
this.keepEmptyPicturePlaceholders = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedKeepEmptyPicturePlaceholders;
|
||||
this.numberOfBulletPoints = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedMaxBulletPointsPerSlide;
|
||||
this.pictureLibraryPath = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedPictureLibraryPath;
|
||||
this.StartCountingPictureLibrary();
|
||||
this.layoutNames.Clear();
|
||||
foreach (var (purpose, layoutName) in this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedLayouts)
|
||||
this.layoutNames[purpose] = layoutName;
|
||||
|
||||
this.selectedTargetLanguage = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTargetLanguage;
|
||||
this.customTargetLanguage = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherLanguage;
|
||||
this.selectedAudienceProfile = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceProfile;
|
||||
this.selectedAudienceAgeGroup = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceAgeGroup;
|
||||
this.selectedAudienceOrganizationalLevel = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceOrganizationalLevel;
|
||||
this.selectedAudienceExpertise = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceExpertise;
|
||||
this.selectedOccasion = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOccasion;
|
||||
this.customOccasion = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherOccasion;
|
||||
this.importantAspects = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedImportantAspects;
|
||||
return true;
|
||||
}
|
||||
@@ -193,12 +271,26 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
private AudienceOrganizationalLevel selectedAudienceOrganizationalLevel;
|
||||
private AudienceExpertise selectedAudienceExpertise;
|
||||
private CommonLanguages selectedTargetLanguage;
|
||||
private PresentationOccasion selectedOccasion;
|
||||
private string customOccasion = string.Empty;
|
||||
private int numberOfSheets;
|
||||
private int numberOfBulletPoints;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the organization dictates the maximum number of bullet points per slide, e.g., to fit its template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Like the template, the maximum is a preselection, so a lock binds only while preselection is enabled.
|
||||
/// </remarks>
|
||||
private bool IsBulletPointMaximumLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedMaxBulletPointsPerSlide, out var meta) && meta.IsLocked;
|
||||
private int timeSpecification;
|
||||
private int calculatedNumberOfSlides;
|
||||
private string importantAspects = string.Empty;
|
||||
private HashSet<FileAttachment> loadedDocumentPaths = [];
|
||||
|
||||
/// <summary>
|
||||
/// The pictures the presentation must show, each on exactly one slide; source material lives in loadedDocumentPaths.
|
||||
/// </summary>
|
||||
private HashSet<FileAttachment> slidePictures = [];
|
||||
private static readonly AssistantSessionStateKey<string> INPUT_TITLE_STATE_KEY = new(nameof(inputTitle));
|
||||
private static readonly AssistantSessionStateKey<string> INPUT_CONTENT_STATE_KEY = new(nameof(inputContent));
|
||||
private static readonly AssistantSessionStateKey<string> CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage));
|
||||
@@ -207,12 +299,14 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
private static readonly AssistantSessionStateKey<AudienceOrganizationalLevel> SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY = new(nameof(selectedAudienceOrganizationalLevel));
|
||||
private static readonly AssistantSessionStateKey<AudienceExpertise> SELECTED_AUDIENCE_EXPERTISE_STATE_KEY = new(nameof(selectedAudienceExpertise));
|
||||
private static readonly AssistantSessionStateKey<CommonLanguages> SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage));
|
||||
private static readonly AssistantSessionStateKey<PresentationOccasion> SELECTED_OCCASION_STATE_KEY = new(nameof(selectedOccasion));
|
||||
private static readonly AssistantSessionStateKey<string> CUSTOM_OCCASION_STATE_KEY = new(nameof(customOccasion));
|
||||
private static readonly AssistantSessionStateKey<int> NUMBER_OF_SHEETS_STATE_KEY = new(nameof(numberOfSheets));
|
||||
private static readonly AssistantSessionStateKey<int> NUMBER_OF_BULLET_POINTS_STATE_KEY = new(nameof(numberOfBulletPoints));
|
||||
private static readonly AssistantSessionStateKey<int> TIME_SPECIFICATION_STATE_KEY = new(nameof(timeSpecification));
|
||||
private static readonly AssistantSessionStateKey<int> CALCULATED_NUMBER_OF_SLIDES_STATE_KEY = new(nameof(calculatedNumberOfSlides));
|
||||
private static readonly AssistantSessionStateKey<string> IMPORTANT_ASPECTS_STATE_KEY = new(nameof(importantAspects));
|
||||
private static readonly AssistantSessionStateKey<HashSet<FileAttachment>> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths));
|
||||
private static readonly AssistantSessionStateKey<HashSet<FileAttachment>> SLIDE_PICTURES_STATE_KEY = new(nameof(slidePictures));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state)
|
||||
@@ -225,12 +319,28 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
state.Set(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, this.selectedAudienceOrganizationalLevel);
|
||||
state.Set(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, this.selectedAudienceExpertise);
|
||||
state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage);
|
||||
state.Set(SELECTED_OCCASION_STATE_KEY, this.selectedOccasion);
|
||||
state.Set(CUSTOM_OCCASION_STATE_KEY, this.customOccasion);
|
||||
state.Set(NUMBER_OF_SHEETS_STATE_KEY, this.numberOfSheets);
|
||||
state.Set(NUMBER_OF_BULLET_POINTS_STATE_KEY, this.numberOfBulletPoints);
|
||||
state.Set(TIME_SPECIFICATION_STATE_KEY, this.timeSpecification);
|
||||
state.Set(CALCULATED_NUMBER_OF_SLIDES_STATE_KEY, this.calculatedNumberOfSlides);
|
||||
state.Set(IMPORTANT_ASPECTS_STATE_KEY, this.importantAspects);
|
||||
state.SetHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
|
||||
state.SetHashSet(SLIDE_PICTURES_STATE_KEY, this.slidePictures);
|
||||
state.Set(TEMPLATE_PATH_STATE_KEY, this.templatePath);
|
||||
state.Set(FOOTER_TEXT_STATE_KEY, this.footerText);
|
||||
state.Set(KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY, this.keepEmptyPicturePlaceholders);
|
||||
state.Set(VARIANT_LABEL_STATE_KEY, this.variantLabel ?? string.Empty);
|
||||
state.Set(ADVANCED_OPTIONS_EXPANDED_STATE_KEY, this.advancedOptionsExpanded);
|
||||
state.SetList(TEMPLATE_SLIDE_USES_STATE_KEY, this.templateSlideUses);
|
||||
state.SetDictionary(LAYOUT_NAMES_STATE_KEY, this.layoutNames);
|
||||
state.Set(SLIDE_DECK_ANSWER_STATE_KEY, this.slideDeckAnswer);
|
||||
state.Set(SLIDE_DECK_TEMPLATE_PATH_STATE_KEY, this.slideDeckTemplatePath);
|
||||
state.SetDictionary(SLIDE_DECK_PICTURE_PATHS_STATE_KEY, this.slideDeckPicturePaths);
|
||||
state.SetHashSet(SLIDE_DECK_ICON_NUMBERS_STATE_KEY, this.slideDeckIconNumbers);
|
||||
state.Set(PICTURE_LIBRARY_PATH_STATE_KEY, this.pictureLibraryPath);
|
||||
state.SetDictionary(SLIDE_DECK_BASIS_STATE_KEY, this.slideDeckBasis);
|
||||
state.Set(PLANNING_STEP_STATE_KEY, this.planningStep);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -244,12 +354,33 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
state.Restore(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, value => this.selectedAudienceOrganizationalLevel = value);
|
||||
state.Restore(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, value => this.selectedAudienceExpertise = value);
|
||||
state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value);
|
||||
state.Restore(SELECTED_OCCASION_STATE_KEY, value => this.selectedOccasion = value);
|
||||
state.Restore(CUSTOM_OCCASION_STATE_KEY, value => this.customOccasion = value);
|
||||
state.Restore(NUMBER_OF_SHEETS_STATE_KEY, value => this.numberOfSheets = value);
|
||||
state.Restore(NUMBER_OF_BULLET_POINTS_STATE_KEY, value => this.numberOfBulletPoints = value);
|
||||
state.Restore(TIME_SPECIFICATION_STATE_KEY, value => this.timeSpecification = value);
|
||||
state.Restore(CALCULATED_NUMBER_OF_SLIDES_STATE_KEY, value => this.calculatedNumberOfSlides = value);
|
||||
state.Restore(IMPORTANT_ASPECTS_STATE_KEY, value => this.importantAspects = value);
|
||||
state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
|
||||
state.RestoreHashSet(SLIDE_PICTURES_STATE_KEY, this.slidePictures);
|
||||
state.Restore(TEMPLATE_PATH_STATE_KEY, value => this.templatePath = value);
|
||||
state.Restore(FOOTER_TEXT_STATE_KEY, value => this.footerText = value);
|
||||
state.Restore(KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY, value => this.keepEmptyPicturePlaceholders = value);
|
||||
state.Restore(VARIANT_LABEL_STATE_KEY, value => this.variantLabel = value.Length > 0 ? value : null);
|
||||
state.Restore(ADVANCED_OPTIONS_EXPANDED_STATE_KEY, value => this.advancedOptionsExpanded = value);
|
||||
state.Restore(TEMPLATE_SLIDE_USES_STATE_KEY, value => this.templateSlideUses = value.ToList());
|
||||
state.RestoreDictionary(LAYOUT_NAMES_STATE_KEY, this.layoutNames);
|
||||
state.Restore(SLIDE_DECK_ANSWER_STATE_KEY, value => this.slideDeckAnswer = value);
|
||||
state.Restore(SLIDE_DECK_TEMPLATE_PATH_STATE_KEY, value => this.slideDeckTemplatePath = value);
|
||||
state.RestoreDictionary(SLIDE_DECK_PICTURE_PATHS_STATE_KEY, this.slideDeckPicturePaths);
|
||||
state.RestoreHashSet(SLIDE_DECK_ICON_NUMBERS_STATE_KEY, this.slideDeckIconNumbers);
|
||||
state.Restore(PICTURE_LIBRARY_PATH_STATE_KEY, value => this.pictureLibraryPath = value);
|
||||
state.RestoreDictionary(SLIDE_DECK_BASIS_STATE_KEY, this.slideDeckBasis);
|
||||
state.Restore(PLANNING_STEP_STATE_KEY, value => this.planningStep = value);
|
||||
|
||||
// Neither the template nor the slides are kept themselves, only what they were read from:
|
||||
this.LoadTemplate();
|
||||
this.RestoreSlideDeck();
|
||||
this.StartCountingPictureLibrary();
|
||||
}
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
@@ -280,13 +411,41 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool HasValidInputDocuments() => this.loadedDocumentPaths.Any(n => n is { Exists: true });
|
||||
private bool HasValidInputDocuments() => this.loadedDocumentPaths.Any(n => n is { Exists: true }) || this.slidePictures.Any(n => n is { Exists: true });
|
||||
|
||||
private async Task OnDocumentsChanged(HashSet<FileAttachment> _)
|
||||
{
|
||||
await this.KeepSlidePicturesOutOfDocumentsAsync();
|
||||
if(this.Form is not null)
|
||||
await this.Form.Validate();
|
||||
}
|
||||
|
||||
private async Task OnSlidePicturesChanged(HashSet<FileAttachment> _)
|
||||
{
|
||||
await this.KeepSlidePicturesOutOfDocumentsAsync();
|
||||
if(this.Form is not null)
|
||||
await this.Form.Validate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the pictures for the slides out of the source material.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A picture is either source material, which the LLM only reads, or a picture for the slides,
|
||||
/// which the presentation must show; it cannot be both. The pictures for the slides win, since
|
||||
/// the documents take every file, while the pictures take only what a slide can show. Without
|
||||
/// the warning, the file would vanish from the documents without a word.
|
||||
/// </remarks>
|
||||
private async Task KeepSlidePicturesOutOfDocumentsAsync()
|
||||
{
|
||||
var picturePaths = this.slidePictures.Select(picture => picture.FilePath).ToHashSet(PathTools.COMPARER);
|
||||
var displaced = this.loadedDocumentPaths.Where(document => picturePaths.Contains(document.FilePath)).ToArray();
|
||||
if (displaced.Length == 0)
|
||||
return;
|
||||
|
||||
this.loadedDocumentPaths.ExceptWith(displaced);
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, string.Format(T("These files are already attached as pictures for the slides and were removed from the documents: {0}"), string.Join(", ", displaced.Select(document => document.FileName)))));
|
||||
}
|
||||
|
||||
private string? ValidateCustomLanguage(string language)
|
||||
{
|
||||
@@ -296,11 +455,14 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
return null;
|
||||
}
|
||||
|
||||
private int CalculateNumberOfSlides()
|
||||
private string? ValidateCustomOccasion(string occasion)
|
||||
{
|
||||
return this.calculatedNumberOfSlides = (int)Math.Round(this.timeSpecification / 1.5);
|
||||
if(this.selectedOccasion == PresentationOccasion.OTHER && string.IsNullOrWhiteSpace(occasion))
|
||||
return T("Please describe the occasion.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private string PromptImportantAspects()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.importantAspects))
|
||||
@@ -358,7 +520,7 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> PromptLoadDocumentsContent()
|
||||
private async Task<string> PromptLoadDocumentsContent(PromptInjectionReportingScope promptInjectionScope)
|
||||
{
|
||||
if (this.loadedDocumentPaths.Count == 0)
|
||||
return string.Empty;
|
||||
@@ -374,13 +536,6 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
""");
|
||||
}
|
||||
|
||||
//
|
||||
// One report for the whole batch: reading twenty documents must produce one dialog
|
||||
// listing all of them, not twenty dialogs in a row.
|
||||
//
|
||||
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
|
||||
await using var promptInjectionScope = guardService.BeginAction();
|
||||
|
||||
var numDocuments = 1;
|
||||
foreach (var document in documents)
|
||||
{
|
||||
@@ -434,9 +589,9 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
{
|
||||
sb.AppendLine($"""
|
||||
|
||||
There are {numImages} image file(s) attached as documents.
|
||||
There are {numImages} image file(s) attached as source material.
|
||||
Please consider them as documents as well and use them to
|
||||
answer accordingly.
|
||||
answer accordingly, but never place them on a slide.
|
||||
|
||||
""");
|
||||
}
|
||||
@@ -444,9 +599,9 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
{
|
||||
sb.AppendLine($"""
|
||||
|
||||
Additionally, there are {numImages} image file(s) attached.
|
||||
Additionally, there are {numImages} image file(s) attached as source material.
|
||||
Please consider them as documents as well and use them to
|
||||
answer accordingly.
|
||||
answer accordingly, but never place them on a slide.
|
||||
|
||||
""");
|
||||
}
|
||||
@@ -460,14 +615,56 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
await this.Form!.Validate();
|
||||
if (!this.InputIsValid)
|
||||
return;
|
||||
|
||||
this.calculatedNumberOfSlides = this.timeSpecification > 0 ? this.CalculateNumberOfSlides() : 0;
|
||||
|
||||
this.CreateChatThread();
|
||||
var documentContent = await this.PromptLoadDocumentsContent();
|
||||
var imageAttachments = this.loadedDocumentPaths.Where(n => n is { Exists: true, IsImage: true }).ToList();
|
||||
|
||||
var time = this.AddUserRequest(
|
||||
//
|
||||
// The slides of an earlier run stay out of sight until this run has a result of its own.
|
||||
// Catalog, template, and pictures are taken once, so that the prompt, the check, and the
|
||||
// file later on all see the same layouts and the same numbering, even when the user
|
||||
// chooses another template while the LLM is still planning:
|
||||
//
|
||||
this.slideDeck = null;
|
||||
this.slideDeckAnswer = string.Empty;
|
||||
this.slideDeckBasis.Clear();
|
||||
this.slideDeckPicturePaths.Clear();
|
||||
this.slideDeckIconNumbers.Clear();
|
||||
this.planningStep = PlanningStep.NONE;
|
||||
var catalog = this.Catalog;
|
||||
var plannedTemplatePath = this.template is null ? string.Empty : this.templatePath;
|
||||
var chosenTemplateSlideUses = this.ChosenTemplateSlideUses;
|
||||
var chosenLayouts = this.ChosenLayoutsOfTemplate;
|
||||
var imageAttachments = this.PicturesOfRequest();
|
||||
|
||||
//
|
||||
// The pictures of the library follow the pictures for the slides in one numbering, so the
|
||||
// JSON names every picture the same way. The system prompt needs the library only while
|
||||
// it is written, so the planning keeps it no longer than that:
|
||||
//
|
||||
var library = await this.ReadPictureLibraryForPlanningAsync();
|
||||
var firstLibraryNumber = imageAttachments.Count + 1;
|
||||
var libraryPictures = new LibraryPictures(library.Pictures.Count, library.Pictures.Select((picture, index) => (Picture: picture, Number: firstLibraryNumber + index)).Where(entry => entry.Picture.IsIcon).Select(entry => entry.Number).ToHashSet());
|
||||
this.planningLibrary = library;
|
||||
this.CreateChatThread();
|
||||
this.planningLibrary = PictureLibraryContent.NONE;
|
||||
|
||||
//
|
||||
// One report for the whole request: reading twenty documents must produce one dialog
|
||||
// listing all of them, not twenty dialogs in a row. The layouts and the slides of the
|
||||
// template come from a file as well, so they belong to the same report:
|
||||
//
|
||||
string documentContent;
|
||||
string layouts;
|
||||
string templateSlides;
|
||||
string pictureLibrary;
|
||||
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
|
||||
await using (var promptInjectionScope = guardService.BeginAction())
|
||||
{
|
||||
documentContent = await this.PromptLoadDocumentsContent(promptInjectionScope);
|
||||
layouts = await PromptLayoutsAsync(guardService, promptInjectionScope, catalog, plannedTemplatePath);
|
||||
templateSlides = await PromptTemplateSlidesAsync(guardService, promptInjectionScope, catalog, plannedTemplatePath, chosenTemplateSlideUses);
|
||||
pictureLibrary = await PromptPictureLibraryAsync(guardService, promptInjectionScope, library, firstLibraryNumber, this.pictureLibraryPath);
|
||||
}
|
||||
|
||||
var request =
|
||||
$"""
|
||||
# PRESENTATION_TITLE
|
||||
```
|
||||
@@ -481,10 +678,93 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
```
|
||||
|
||||
{documentContent}
|
||||
""",
|
||||
hideContentFromUser: true,
|
||||
imageAttachments);
|
||||
{layouts}
|
||||
{PromptPictureList(imageAttachments, this.ProviderSettings.SupportsImageInput())}
|
||||
{pictureLibrary}
|
||||
{templateSlides}
|
||||
""";
|
||||
|
||||
await this.AddAIResponseAsync(time);
|
||||
//
|
||||
// The pictures for the slides come first, in the order of PICTURES, so that the LLM can tell
|
||||
// them from the images of the source material which follow:
|
||||
//
|
||||
var time = this.AddUserRequest(request, hideContentFromUser: true, [..imageAttachments, ..this.SourceImagesOfRequest()]);
|
||||
|
||||
this.planningStep = PlanningStep.PLANNING;
|
||||
var answer = await this.AddAIResponseAsync(time, hideContentFromUser: true);
|
||||
|
||||
// The provider failed and the base class has said so already:
|
||||
if (string.IsNullOrWhiteSpace(answer))
|
||||
return;
|
||||
|
||||
var (deck, issues) = this.CheckAnswer(answer, catalog, imageAttachments.Count, libraryPictures, chosenTemplateSlideUses, chosenLayouts);
|
||||
var neededRepairRound = deck is null || issues.Count > 0;
|
||||
if (neededRepairRound)
|
||||
{
|
||||
//
|
||||
// One repair round in the same chat, so the model sees its own answer next to what is
|
||||
// wrong with it. A repaired answer which cannot be read at all is worse than the first
|
||||
// one, though, so it only replaces an answer it improves on:
|
||||
//
|
||||
var repairTime = this.AddUserRequest(RepairPromptOf(issues), hideContentFromUser: true);
|
||||
this.planningStep = PlanningStep.REPAIRING;
|
||||
var repairedAnswer = await this.AddAIResponseAsync(repairTime, hideContentFromUser: true);
|
||||
var (repairedDeck, repairedIssues) = string.IsNullOrWhiteSpace(repairedAnswer) ? new CheckedAnswer(null, issues) : this.CheckAnswer(repairedAnswer, catalog, imageAttachments.Count, libraryPictures, chosenTemplateSlideUses, chosenLayouts);
|
||||
if (repairedDeck is not null)
|
||||
{
|
||||
answer = repairedAnswer;
|
||||
deck = repairedDeck;
|
||||
issues = repairedIssues;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsLibraryNumber(int number) => number >= firstLibraryNumber && number < firstLibraryNumber + library.Pictures.Count;
|
||||
var libraryPlacements = deck?.Slides.SelectMany(slide => slide.Pictures).Where(IsLibraryNumber).ToList() ?? [];
|
||||
this.Logger.LogInformation("The slide assistant asked with {RequestCharacters} characters, {PictureCount} pictures for the slides, and {LibraryPictureCount} pictures of the library, {LibraryIconCount} of them icons, and got {SlideCount} slides, {RepairRound}. The slides place {LibraryPlacementCount} pictures of the library, {LibraryIconPlacementCount} of them icons.", request.Length, imageAttachments.Count, library.Pictures.Count, library.Pictures.Count(picture => picture.IsIcon), deck?.Slides.Count ?? 0, neededRepairRound ? "after a repair round" : "without a repair round", libraryPlacements.Count, libraryPlacements.Count(number => library.Pictures[number - firstLibraryNumber].IsIcon));
|
||||
|
||||
//
|
||||
// Slides on layouts the template does not have, or on template slides the user did not
|
||||
// choose, cannot be shown, let alone written. Any other issue, such as a text which runs
|
||||
// long, leaves slides worth looking at:
|
||||
//
|
||||
if (deck is null || deck.Slides.Count == 0 || deck.Slides.Any(slide => !SlideDeckSpecValidator.CanWrite(slide, catalog, chosenTemplateSlideUses)))
|
||||
{
|
||||
this.Logger.LogWarning("The slides the model planned cannot be used: {Issues}", string.Join(" | ", issues));
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The LLM did not describe the slides in a usable form, even after a second request. Please try again, or choose another model.")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
this.Logger.LogInformation("The slides the model planned still have issues after the repair round: {Issues}", string.Join(" | ", issues));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Slideshow, T("Not every slide fits its layout as planned; for example, a text may run long. Please check the slides before presenting.")));
|
||||
}
|
||||
|
||||
this.slideDeck = deck;
|
||||
this.slideDeckAnswer = answer;
|
||||
this.slideDeckTemplatePath = plannedTemplatePath;
|
||||
foreach (var (id, description) in catalog.DescribeBasisOf(deck))
|
||||
this.slideDeckBasis[id] = description;
|
||||
|
||||
//
|
||||
// Of the library, only the pictures the deck uses are kept, not the thousands it was offered:
|
||||
//
|
||||
var pictureNames = new Dictionary<int, string>();
|
||||
for (var index = 0; index < imageAttachments.Count; index++)
|
||||
{
|
||||
this.slideDeckPicturePaths[index + 1] = imageAttachments[index].FilePath;
|
||||
pictureNames[index + 1] = imageAttachments[index].FileName;
|
||||
}
|
||||
|
||||
foreach (var number in libraryPlacements.Distinct())
|
||||
{
|
||||
var picture = library.Pictures[number - firstLibraryNumber];
|
||||
this.slideDeckPicturePaths[number] = picture.FilePath;
|
||||
pictureNames[number] = picture.Name;
|
||||
if (picture.IsIcon)
|
||||
this.slideDeckIconNumbers.Add(number);
|
||||
}
|
||||
|
||||
this.ShowOutline(deck, catalog, pictureNames);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// The explanations which the Slide Assistant and its settings dialog share, so that both say the same.
|
||||
/// </summary>
|
||||
public static class SlideAssistantTexts
|
||||
{
|
||||
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(SlideAssistantTexts).Namespace, nameof(SlideAssistantTexts));
|
||||
|
||||
/// <summary>
|
||||
/// What the LLM knows about the layouts of a template, and why their names matter.
|
||||
/// </summary>
|
||||
public static string WhatTheLLMKnowsOfLayouts => TB("The LLM does not see what your layouts look like. It knows only their names from the slide master of your template, where their placeholders sit, and how much text fits into them. Clear names such as “Chapter” or “Content with picture” therefore help it choose well; in PowerPoint, you rename a layout in the slide master view. Logo, background, and footer come with the layout. Layouts which differ only in their color count as one, and the color applies to the whole presentation.");
|
||||
|
||||
/// <summary>
|
||||
/// What the choice of a layout for a kind of slide does, and what it does not.
|
||||
/// </summary>
|
||||
public static string WhatALayoutChoiceDoes => TB("Choose which layout of your template the LLM uses for certain kinds of slides. The lists offer the layouts with room for content. Where you have no preference, the LLM decides by the names and placeholders of the layouts. A choice says which layout such a slide uses, not whether the presentation has one: whether there is, e.g., an agenda, follows from your content and your important aspects. Only for the framing slides, e.g., the table of contents or the imprint, does a choice also add such a slide; their help says so. Each chapter becomes a section in PowerPoint, which the slide sorter shows as an outline.");
|
||||
|
||||
/// <summary>
|
||||
/// What keeping the empty picture placeholders does.
|
||||
/// </summary>
|
||||
public static string WhatKeepingEmptyPicturePlaceholdersDoes => TB("When a new slide has no picture for a picture placeholder of its layout, it leaves the placeholder out, as every other placeholder without content. Keep these placeholders to insert pictures of your own later: PowerPoint shows them while you edit and hides them in the slide show.");
|
||||
|
||||
/// <summary>
|
||||
/// What a picture library is, which of its files count, and how the LLM uses them.
|
||||
/// </summary>
|
||||
public static string WhatAPictureLibraryOffers => string.Format(TB("Choose a folder with pictures and icons, e.g., the picture library of your organization. The LLM reads only their names, never the pictures themselves, and uses a picture where its name fits a slide; it does not have to use any. So clear names such as “rocket-launch.jpg” help. Only PNG and JPEG pictures count: many icons come as SVG, which the assistant leaves out. A picture whose name, or the name of one of its folders, the chosen folder included, starts with “icon” is an icon. An icon may appear on several slides and goes into picture placeholders of any size, except those which cover half the slide or more, as a background picture does. From a folder with more than {0} pictures, the LLM gets a random {0} of them for each planning."), MaxLibraryPictures);
|
||||
|
||||
/// <summary>
|
||||
/// The most pictures of a library the LLM reads about, as the user reads the number.
|
||||
/// </summary>
|
||||
public static string MaxLibraryPictures => PictureLibrary.MAX_LIBRARY_PICTURES.ToString("N0", Tools.PluginSystem.I18N.I.Culture);
|
||||
|
||||
/// <summary>
|
||||
/// What the presentation does with the slides a template carries besides its layouts.
|
||||
/// </summary>
|
||||
public static string WhatHappensToTemplateSlides => TB("Besides its layouts, your file contains slides, e.g., samples of the template or the slides of an earlier presentation. By default, the presentation leaves them out. Choose for each slide whether the presentation keeps it unchanged or lets the LLM edit its content. The LLM uses each of these slides exactly once, keeps them in their order, and may add new slides before, between, and after them. It never copies a slide as a pattern: new slides always come from the layouts of your template.");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
|
||||
internal static class TemplateSlideExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(TemplateSlideExtensions).Namespace, nameof(TemplateSlideExtensions));
|
||||
|
||||
/// <summary>
|
||||
/// How many characters of its title a slide of the template shows in its label.
|
||||
/// </summary>
|
||||
private const int MAX_SLIDE_TITLE_CHARACTERS = 60;
|
||||
|
||||
/// <summary>
|
||||
/// How the forms name a slide of the template: by its number and its title, and what is special about it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The title tells the slides apart at a glance, since a template rarely gives its slides more
|
||||
/// than a title. A long one is cut, so that the label stays on one line.
|
||||
/// </remarks>
|
||||
public static string Label(this TemplateSlide slide)
|
||||
{
|
||||
var title = slide.Title.Length > MAX_SLIDE_TITLE_CHARACTERS ? $"{slide.Title[..MAX_SLIDE_TITLE_CHARACTERS].TrimEnd()}…" : slide.Title;
|
||||
var label = title.Length > 0 ? string.Format(TB("Slide {0}: {1}"), slide.Number, title) : string.Format(TB("Slide {0}"), slide.Number);
|
||||
if (slide.IsHidden)
|
||||
label = $"{label} ({TB("hidden")})";
|
||||
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Assistants.SlideBuilder;
|
||||
|
||||
internal static class TemplateSlideUseExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(TemplateSlideUseExtensions).Namespace, nameof(TemplateSlideUseExtensions));
|
||||
|
||||
public static string Name(this TemplateSlideUse use) => use switch
|
||||
{
|
||||
TemplateSlideUse.OMIT => TB("Leave out"),
|
||||
TemplateSlideUse.KEEP => TB("Keep unchanged"),
|
||||
TemplateSlideUse.EDIT => TB("Let the LLM edit it"),
|
||||
|
||||
_ => TB("Leave out"),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user