2026-08-02 21:41:12 +02:00
using System.Reflection ;
using System.Security.Cryptography ;
using System.Text ;
using System.Text.Json ;
using System.Text.RegularExpressions ;
using AIStudio.Tools.Metadata ;
using HtmlAgilityPack ;
namespace AIStudio.Assistants.VisualBriefing ;
public sealed partial class VisualBriefingArtifactService
{
/// <summary>
/// Lazily loads the official MindWork AI Studio icon for self-contained exports.
/// </summary>
private static readonly Lazy < string > BRAND_ICON_DATA_URI = new ( LoadBrandIconDataUri );
/// <summary>
/// Assembles one self-contained briefing HTML file from validated parts.
/// </summary>
/// <remarks>
/// Assembly itself is synchronous; the task-based signature exists because callers run it inside
/// cancellable pipeline stages.
/// </remarks>
/// <param name="manifest">The briefing manifest.</param>
/// <param name="request">The validated revision request.</param>
/// <param name="lockedRuntimeScript">An existing runtime script to reuse, keeping a revision reproducible.</param>
/// <param name="lockedEChartsScript">An existing chart runtime to reuse, keeping a revision reproducible.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The complete standalone HTML document.</returns>
public Task < string > BuildAsync ( VisualBriefingManifest manifest , VisualBriefingRevisionRequest request , string? lockedRuntimeScript = null , string? lockedEChartsScript = null , CancellationToken token = default )
{
token . ThrowIfCancellationRequested ();
var data = AddProtectedArtifactData ( manifest , request );
var usesCharts = ContainsChartBinding ( request . TemplateHtml );
var validationIssue = ValidateGeneratedParts ( manifest , data , request . TemplateHtml , request . Css , usesCharts );
if (! string . IsNullOrEmpty ( validationIssue ))
throw new InvalidDataException ( validationIssue );
var dataJson = JsonSerializer . Serialize ( data , JSON_OPTIONS );
var template = CanonicalizeTemplate ( request . TemplateHtml );
var css = request . Css . Trim ();
var runtime = lockedRuntimeScript ?? this . RuntimeScript ;
var runtimeAIStudioVersion = ExtractRuntimeAIStudioVersion ( runtime ) ?? throw new InvalidDataException ( "The AI Studio runtime does not contain a valid originating app version." );
var echarts = usesCharts ? lockedEChartsScript ?? ECHARTS_SCRIPT . Value : null ;
if ( usesCharts && string . IsNullOrWhiteSpace ( echarts ))
throw new InvalidOperationException ( "Apache ECharts 6.1.0 common is not available in this AI Studio build." );
var exportMetadata = request . ExportMetadataSource ;
var htmlLanguage = GetHtmlLanguage (
exportMetadata ?. TargetLanguage ?? manifest . Settings . TargetLanguage ,
exportMetadata ?. CustomTargetLanguage ?? manifest . Settings . CustomTargetLanguage );
var briefingName = exportMetadata ?. Name ?? manifest . Name ;
var exportManifest = CreateExportManifest ( manifest , request , DOCUMENT_HASH_PLACEHOLDER , this . AIStudioVersion , runtimeAIStudioVersion );
var parts = new VisualBriefingArtifactParts ( exportManifest , data , template , css , runtime , echarts , DOCUMENT_HASH_PLACEHOLDER );
var csp = GetContentSecurityPolicy ( parts );
var placeholderDocument = AssembleDocument ( exportManifest , htmlLanguage , briefingName , dataJson , template , css , runtime , echarts , csp );
exportManifest . DocumentHash = VisualBriefingHashing . Compute ( placeholderDocument );
return Task . FromResult ( AssembleDocument ( exportManifest , htmlLanguage , briefingName , dataJson , template , css , runtime , echarts , csp ));
}
/// <summary>
/// Assembles the deterministic document around a supplied artifact header.
/// </summary>
private static string AssembleDocument ( VisualBriefingExportManifest exportManifest , string htmlLanguage , string briefingName , string dataJson , string template , string css , string runtime , string? echarts , string csp )
{
var encodedHeader = EncodeHeader ( exportManifest );
return $"""
<!doctype html>
<!--{HEADER_MARKER}{encodedHeader}-->
<html lang=" { htmlLanguage } ">
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< meta http - equiv = "Content-Security-Policy" content = "{csp}" >
< meta name = "referrer" content = "no-referrer" >
< title >{ HtmlEncode ( briefingName )}</ title >
< style id = "mwai-briefing-style" >{ css }</ style >
< style id = "mwai-protected-style" >{ PROTECTED_STATIC_CSS }</ style >
</ head >
< body >
< script id = "{DATA_ELEMENT_ID}" type = "application/json" >{ dataJson }</ script >
< header id = "mwai-static-header" >
{ BuildStaticHeaderTemplate ()}
</ header >
< div id = "mwai-briefing-root" >{ template }</ div >
< footer id = "mwai-static-footer" class = "mwai-footer" >
{ STATIC_FOOTER_TEMPLATE }
</ footer >
{ BuildScriptTag ( echarts , "mwai-echarts-runtime" )}
< script id = "mwai-briefing-runtime" >{ runtime }</ script >
</ body >
</ html >
""";
}
/// <summary>
/// Encodes the stable JSON artifact header for embedding in an HTML comment.
/// </summary>
/// <remarks>
/// The header is canonical JSON because verifying a stored briefing encodes it again and compares
/// the document hash. Plain serialization would tie every stored document to the order in which the
/// manifest properties happen to be declared, so moving one property would reject every briefing
/// ever exported.
/// </remarks>
private static string EncodeHeader ( VisualBriefingExportManifest exportManifest ) => Convert . ToBase64String ( Encoding . UTF8 . GetBytes ( VisualBriefingHashing . CanonicalJson ( exportManifest )));
/// <summary>
/// Defines <c>RuntimeAIVersionRegex</c> for the visual briefing feature.
/// </summary>
private static readonly Regex RUNTIME_AI_VERSION_REGEX = RuntimeAIVersionRegex ();
/// <summary>
/// Defines <c>RuntimeAIVersionRegex</c> for the visual briefing feature.
/// </summary>
[GeneratedRegex("""const AI_STUDIO_VERSION = (?<value>"(?:\\.|[^"\\] )* ");""" , RegexOptions . CultureInvariant )]
private static partial Regex RuntimeAIVersionRegex ();
/// <summary>
/// Builds the protected, app-owned static header template.
/// </summary>
private static string BuildStaticHeaderTemplate () => $"""
<img src=" { BRAND_ICON_DATA_URI . Value } " width=" 32 " height=" 32 " alt="" aria-hidden=" true ">
< a href = "{PROJECT_URL}" target = "_blank" rel = "noopener noreferrer" > MINDWORK AI STUDIO </ a >
""";
/// <summary>
/// Loads the official app icon as a Data URL so exported briefings remain self-contained.
/// </summary>
private static string LoadBrandIconDataUri ()
{
var assembly = Assembly . GetExecutingAssembly ();
using var stream = assembly . GetManifestResourceStream ( "AIStudio.Assistants.VisualBriefing.Runtime.mindwork-ai-studio-icon.png" ) ??
throw new InvalidOperationException ( "The official MindWork AI Studio icon is not available in this build." );
using var buffer = new MemoryStream ();
stream . CopyTo ( buffer );
return $"data:image/png;base64,{Convert.ToBase64String(buffer.ToArray())}" ;
}
/// <summary>
/// Links exported MindWork AI Studio branding to the project repository.
/// </summary>
private const string PROJECT_URL = "https://github.com/MindWorkAI/AI-Studio" ;
/// <summary>
/// Defines the protected, app-owned static footer template.
/// </summary>
private const string STATIC_FOOTER_TEMPLATE = $"""
<span>Created with <a href=" { PROJECT_URL } " target=" _blank " rel=" noopener noreferrer ">MindWork AI Studio</a> v<span data-mwai-text=" _mwai . aiStudioVersion "></span>.</span>
< span data - mwai - text = "_mwai.footer.models" ></ span >
< span data - mwai - text = "_mwai.footer.createdAt" ></ span >
< span data - mwai - text = "_mwai.footer.authors" ></ span >
< span data - mwai - text = "_mwai.footer.protection" ></ span >
""";
/// <summary>
/// Defines protected static header and footer styles that model CSS cannot override.
/// </summary>
private const string PROTECTED_STATIC_CSS = """
html {
background : # f3f6f3 ! important ;
}
body {
min - width : 0 ! important ;
margin : 0 ! important ;
background : # f3f6f3 ! important ;
color : # 172 a24 ! important ;
}
# mwai - static - header {
display : flex ! important ;
align - items : center ! important ;
gap : . 75 rem ! important ;
position : relative ! important ;
z - index : 2147483647 ! important ;
visibility : visible ! important ;
opacity : 1 ! important ;
max - width : 80 rem ! important ;
margin : 0 auto ! important ;
padding : clamp ( 1 rem , 3.5 vw , 3 rem ) clamp ( 1 rem , 3.5 vw , 3 rem ) 0 ! important ;
color : # 164 b3b ! important ;
font : 700 . 82 rem / 1.4 system - ui , sans - serif ! important ;
letter - spacing : . 08 em ! important ;
text - transform : uppercase ! important ;
}
# mwai - static - header img {
box - sizing : border - box ! important ;
display : block ! important ;
flex : 0 0 auto ! important ;
visibility : visible ! important ;
opacity : 1 ! important ;
width : 2 rem ! important ;
height : 2 rem ! important ;
border - radius : . 5 rem ! important ;
object - fit : cover ! important ;
}
# mwai - static - header a {
display : inline ! important ;
visibility : visible ! important ;
opacity : 1 ! important ;
color : inherit ! important ;
font : inherit ! important ;
letter - spacing : inherit ! important ;
text - decoration : none ! important ;
}
# mwai - static - header a : hover {
text - decoration : underline ! important ;
text - underline - offset : . 2 em ! important ;
}
# mwai - static - header a : focus - visible {
outline : 3 px solid # f2d264 ! important ;
outline - offset : 3 px ! important ;
}
# mwai - static - footer {
display : flex ! important ;
flex - wrap : wrap ! important ;
gap : . 5 rem 1.25 rem ! important ;
position : relative ! important ;
z - index : 2147483647 ! important ;
visibility : visible ! important ;
opacity : 1 ! important ;
max - width : 74 rem ! important ;
margin : 1 rem auto 0 ! important ;
padding : 1.25 rem clamp ( 1 rem , 3.5 vw , 3 rem ) 2 rem ! important ;
border - top : 1 px solid # d6e2dc ! important ;
color : # 5e7169 ! important ;
font : 12 px / 1.55 system - ui , sans - serif ! important ;
}
# mwai - static - footer span {
display : inline ! important ;
visibility : visible ! important ;
opacity : 1 ! important ;
}
# mwai - static - footer a {
display : inline ! important ;
visibility : visible ! important ;
opacity : 1 ! important ;
color : inherit ! important ;
font : inherit ! important ;
text - decoration : underline ! important ;
text - underline - offset : . 15 em ! important ;
}
@media ( max - width : 47.99 rem ) {
# mwai - static - header {
padding : . 75 rem . 75 rem 0 ! important ;
}
}
@media print {
html , body {
background : # fffefa ! important ;
}
# mwai - static - header {
max - width : none ! important ;
padding : 0 0 12 mm ! important ;
}
# mwai - static - footer {
max - width : none ! important ;
margin - top : 6 mm ! important ;
padding : 4 mm 0 0 ! important ;
}
}
""";
/// <summary>
/// Defines <c>GetContentSecurityPolicy</c> for the visual briefing feature.
/// </summary>
public static string GetContentSecurityPolicy ( VisualBriefingArtifactParts parts )
{
var echartsHash = string . IsNullOrWhiteSpace ( parts . EChartsScript ) ? string . Empty : $" {ScriptCspHash(parts.EChartsScript)}" ;
return $"default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src {ScriptCspHash(parts.RuntimeScript)}{echartsHash}; font-src 'none'; media-src 'none'; frame-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'self'" ;
}
/// <summary>
/// Defines <c>ScriptCspHash</c> for the visual briefing feature.
/// </summary>
private static string ScriptCspHash ( string script ) => $"'sha256-{Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(script)))}'" ;
/// <summary>
/// Defines <c>BuildRuntimeScript</c> for the visual briefing feature.
/// </summary>
private static string BuildRuntimeScript ( string aiStudioVersion ) =>
RUNTIME_SCRIPT . Replace (
"""
"__MWAI_AI_STUDIO_VERSION__"
""",
JsonSerializer . Serialize ( aiStudioVersion , JSON_OPTIONS ),
StringComparison . Ordinal );
/// <summary>
/// Defines <c>ExtractRuntimeAIStudioVersion</c> for the visual briefing feature.
/// </summary>
private static string? ExtractRuntimeAIStudioVersion ( string runtime )
{
var match = RUNTIME_AI_VERSION_REGEX . Match ( runtime );
if (! match . Success )
return null ;
try
{
return JsonSerializer . Deserialize < string >( match . Groups [ "value" ]. Value , JSON_OPTIONS );
}
catch ( JsonException )
{
return null ;
}
}
/// <summary>
/// Defines <c>BuildScriptTag</c> for the visual briefing feature.
/// </summary>
private static string BuildScriptTag ( string? script , string id ) => string . IsNullOrWhiteSpace ( script )
? string . Empty
: $"<script id=\" { id } \ ">{script}</script>" ;
/// <summary>
/// Defines <c>HtmlEncode</c> for the visual briefing feature.
/// </summary>
private static string HtmlEncode ( string value ) => System . Net . WebUtility . HtmlEncode ( value );
/// <summary>
/// Defines <c>ContainsChartBinding</c> for the visual briefing feature.
/// </summary>
private static bool ContainsChartBinding ( string templateHtml )
{
var document = new HtmlDocument ();
document . LoadHtml ( $"<div id=\" chart - detection - root \ ">{templateHtml}</div>" );
var root = FindElementById ( document , "chart-detection-root" );
return root is not null && FindNode ( root , ".//*[@data-mwai-chart]" ) is not null ;
}
/// <summary>
/// Defines <c>CreateExportManifest</c> for the visual briefing feature.
/// </summary>
private static VisualBriefingExportManifest CreateExportManifest ( VisualBriefingManifest manifest , VisualBriefingRevisionRequest request , string documentHash , string aiStudioVersion , string runtimeAIStudioVersion )
{
var source = request . ExportMetadataSource ;
return new ()
{
BriefingId = manifest . BriefingId ,
RevisionId = request . RevisionId ?? Guid . NewGuid (),
ParentRevisionId = request . ParentRevisionId ,
Name = source ?. Name ?? manifest . Name ,
Author = source ?. Author ?? manifest . Author ,
CreatedAtUtc = request . CreatedAtUtc ?? DateTimeOffset . UtcNow ,
TargetLanguage = source ?. TargetLanguage ?? manifest . Settings . TargetLanguage ,
CustomTargetLanguage = source ?. CustomTargetLanguage ?? manifest . Settings . CustomTargetLanguage ,
AudienceProfile = source ?. AudienceProfile ?? manifest . Settings . AudienceProfile ,
AudienceAgeGroup = source ?. AudienceAgeGroup ?? manifest . Settings . AudienceAgeGroup ,
AudienceOrganizationalLevel = source ?. AudienceOrganizationalLevel ?? manifest . Settings . AudienceOrganizationalLevel ,
AudienceExpertise = source ?. AudienceExpertise ?? manifest . Settings . AudienceExpertise ,
ShowSourceReferences = source ?. ShowSourceReferences ?? manifest . Settings . ShowSourceReferences ,
ProtectionLevel = source ?. ProtectionLevel ?? manifest . Settings . ProtectionLevel ,
CustomProtectionLevel = source ?. CustomProtectionLevel ?? manifest . Settings . CustomProtectionLevel ,
AIStudioVersion = aiStudioVersion ,
RuntimeAIStudioVersion = runtimeAIStudioVersion ,
DocumentHash = documentHash ,
};
}
/// <summary>
/// Defines <c>AddProtectedArtifactData</c> for the visual briefing feature.
/// </summary>
2026-11-03 14:20:41 +01:00
/// <remarks>
/// Internal, so that a test can check the JSON it writes.
/// </remarks>
internal static JsonElement AddProtectedArtifactData ( VisualBriefingManifest manifest , VisualBriefingRevisionRequest request )
2026-08-02 21:41:12 +02:00
{
var source = request . Data ;
var dictionary = JsonSerializer . Deserialize < Dictionary < string , JsonElement >>( source . GetRawText (), JSON_OPTIONS ) ?? [];
dictionary . Remove ( "assets" );
dictionary . Remove ( "footerTemplates" );
dictionary . Remove ( "protectionLabel" );
dictionary . Remove ( "_mwai" );
2026-11-03 14:20:41 +01:00
dictionary [ "_mwai" ] = JsonSerializer . SerializeToElement ( new VisualBriefingProtectedData (
VisualBriefingVersions . SCHEMA ,
VisualBriefingVersions . RUNTIME ,
Assembly . GetExecutingAssembly (). GetCustomAttribute < MetaDataAttribute >()?. Version ?? "unknown" ,
request . EmbeddedAssets ?? new Dictionary < string , string >( StringComparer . Ordinal ),
( request . AssetPlan ?? []). ToDictionary (
2026-08-02 21:41:12 +02:00
asset => asset . AssetId ,
2026-11-03 14:20:41 +01:00
asset => new VisualBriefingAssetMetadata ( asset . Description , asset . AltText ),
2026-08-02 21:41:12 +02:00
StringComparer . Ordinal ),
2026-11-03 14:20:41 +01:00
BuildFooter ( manifest , request )), JSON_OPTIONS );
2026-08-02 21:41:12 +02:00
return JsonSerializer . SerializeToElement ( dictionary , JSON_OPTIONS );
}
/// <summary>
/// Defines <c>BuildFooter</c> for the visual briefing feature.
/// </summary>
2026-11-03 14:20:41 +01:00
private static Dictionary < string , string > BuildFooter ( VisualBriefingManifest manifest , VisualBriefingRevisionRequest request )
2026-08-02 21:41:12 +02:00
{
var source = request . ExportMetadataSource ;
var protectionLevel = source ?. ProtectionLevel ?? manifest . Settings . ProtectionLevel ;
var customProtectionLevel = source ?. CustomProtectionLevel ?? manifest . Settings . CustomProtectionLevel ;
var protection = protectionLevel is VisualBriefingProtectionLevel . OTHER
? customProtectionLevel
: protectionLevel . ToString (). Replace ( '_' , ' ' ). ToLowerInvariant ();
var created = ( request . CreatedAtUtc ?? DateTimeOffset . UtcNow ). ToString ( "yyyy-MM-dd" );
var sourceAuthor = source ?. Author ?? manifest . Author ;
var author = string . IsNullOrWhiteSpace ( sourceAuthor ) ? "—" : sourceAuthor ;
var version = Assembly . GetExecutingAssembly (). GetCustomAttribute < MetaDataAttribute >()?. Version ?? "unknown" ;
var contributions = request . ModelContributions ?. Where ( contribution => ! string . IsNullOrWhiteSpace ( contribution . Model ))
. Distinct ()
. ToArray () ?? [];
if ( contributions . Length == 0 && ! string . IsNullOrWhiteSpace ( request . ModelDisplayName ))
contributions = [ new ( VisualBriefingModelRole . CONTENT , request . ModelDisplayName )];
var models = contributions . Length == 0
? "—"
: string . Join (
"; " ,
contributions
. GroupBy ( contribution => contribution . Model , StringComparer . Ordinal )
. Select ( group =>
{
var roles = group . Select ( contribution => contribution . Role is VisualBriefingModelRole . DESIGN ? "presentation" : "content" ). Distinct ( StringComparer . Ordinal );
return $"{group.Key} ({string.Join(" , ", roles)})" ;
}));
// The briefing body follows the chosen target language, but this footer is AI Studio's own
// statement about the artifact and stays US English. Translations shipped inside an exported
// artifact cannot be reviewed the way the app UI can, which uses the language plugin system.
return new Dictionary < string , string >( StringComparer . Ordinal )
{
["createdWith"] = $"Created with MindWork AI Studio v{version}." ,
["models"] = $"Contributing models: {models}." ,
["createdAt"] = $"Revision created on {created}." ,
["authors"] = $"Author(s): {author}." ,
["protection"] = $"Protection level: {protection}." ,
};
}
}