mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 20:52:11 +00:00
347 lines
18 KiB
C#
347 lines
18 KiB
C#
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>
|
|
/// 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 payloadHash = ComputePayloadHash(dataJson, template, css, runtime, echarts);
|
|
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,
|
|
payloadHash,
|
|
this.AIStudioVersion,
|
|
runtimeAIStudioVersion);
|
|
|
|
var encodedManifest = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(exportManifest, JSON_OPTIONS)));
|
|
var csp = GetContentSecurityPolicy(new(exportManifest, data, template, css, runtime, echarts, payloadHash));
|
|
|
|
return Task.FromResult($"""
|
|
<!doctype html>
|
|
<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}
|
|
{PROTECTED_FOOTER_CSS}</style>
|
|
</head>
|
|
<body>
|
|
<!--{MANIFEST_MARKER}{encodedManifest}-->
|
|
<script id="{DATA_ELEMENT_ID}" type="application/json">{dataJson}</script>
|
|
<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>
|
|
/// 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>
|
|
/// Defines the protected, app-owned static footer template.
|
|
/// </summary>
|
|
private const string STATIC_FOOTER_TEMPLATE = """
|
|
<span data-mwai-text="_mwai.footer.createdWith"></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 footer styles that model CSS cannot override.
|
|
/// </summary>
|
|
private const string PROTECTED_FOOTER_CSS = """
|
|
html {
|
|
background: #f3f6f3 !important;
|
|
}
|
|
body {
|
|
min-width: 0 !important;
|
|
margin: 0 !important;
|
|
background: #f3f6f3 !important;
|
|
color: #172a24 !important;
|
|
}
|
|
#mwai-static-footer {
|
|
display: flex !important;
|
|
flex-wrap: wrap !important;
|
|
gap: .5rem 1.25rem !important;
|
|
position: relative !important;
|
|
z-index: 2147483647 !important;
|
|
visibility: visible !important;
|
|
opacity: 1 !important;
|
|
max-width: 74rem !important;
|
|
margin: 1rem auto 0 !important;
|
|
padding: 1.25rem clamp(1rem, 3.5vw, 3rem) 2rem !important;
|
|
border-top: 1px solid #d6e2dc !important;
|
|
color: #5e7169 !important;
|
|
font: 12px/1.55 system-ui, sans-serif !important;
|
|
}
|
|
#mwai-static-footer span {
|
|
display: inline !important;
|
|
visibility: visible !important;
|
|
opacity: 1 !important;
|
|
}
|
|
@media print {
|
|
html, body {
|
|
background: #fffefa !important;
|
|
}
|
|
#mwai-static-footer {
|
|
max-width: none !important;
|
|
margin-top: 6mm !important;
|
|
padding: 4mm 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>ComputePayloadHash</c> for the visual briefing feature.
|
|
/// </summary>
|
|
private static string ComputePayloadHash(string dataJson, string template, string css, string runtime, string? echarts) =>
|
|
VisualBriefingHashing.ComputeSections(dataJson, template, css, runtime, echarts);
|
|
|
|
/// <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 payloadHash,
|
|
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,
|
|
PayloadHash = payloadHash,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Defines <c>AddProtectedArtifactData</c> for the visual briefing feature.
|
|
/// </summary>
|
|
private static JsonElement AddProtectedArtifactData(VisualBriefingManifest manifest, VisualBriefingRevisionRequest request)
|
|
{
|
|
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");
|
|
dictionary["_mwai"] = JsonSerializer.SerializeToElement(new
|
|
{
|
|
schemaVersion = VisualBriefingVersions.SCHEMA,
|
|
runtimeVersion = VisualBriefingVersions.RUNTIME,
|
|
aiStudioVersion = Assembly.GetExecutingAssembly().GetCustomAttribute<MetaDataAttribute>()?.Version ?? "unknown",
|
|
assets = request.EmbeddedAssets ?? new Dictionary<string, string>(StringComparer.Ordinal),
|
|
assetMetadata = (request.AssetPlan ?? []).ToDictionary(
|
|
asset => asset.AssetId,
|
|
asset => new { asset.Description, asset.AltText },
|
|
StringComparer.Ordinal),
|
|
footer = BuildFooter(manifest, request),
|
|
}, JSON_OPTIONS);
|
|
|
|
return JsonSerializer.SerializeToElement(dictionary, JSON_OPTIONS);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Defines <c>BuildFooter</c> for the visual briefing feature.
|
|
/// </summary>
|
|
private static object BuildFooter(VisualBriefingManifest manifest, VisualBriefingRevisionRequest request)
|
|
{
|
|
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}.",
|
|
};
|
|
}
|
|
} |