This commit is contained in:
nilskruthoff 2026-08-10 14:47:31 +02:00 committed by GitHub
commit f08ade315d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 768 additions and 20 deletions

View File

@ -339,6 +339,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
this.ChatThread = new() this.ChatThread = new()
{ {
IncludeDateTime = false, IncludeDateTime = false,
AllowChartOutput = true,
SelectedProvider = this.ProviderSettings.Id, SelectedProvider = this.ProviderSettings.Id,
SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id, SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id,
SystemPrompt = this.SystemPrompt, SystemPrompt = this.SystemPrompt,
@ -355,6 +356,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
this.ChatThread = new() this.ChatThread = new()
{ {
IncludeDateTime = false, IncludeDateTime = false,
AllowChartOutput = true,
SelectedProvider = this.ProviderSettings.Id, SelectedProvider = this.ProviderSettings.Id,
SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id, SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id,
SystemPrompt = this.SystemPrompt, SystemPrompt = this.SystemPrompt,
@ -922,4 +924,4 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected virtual void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) { } protected virtual void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) { }
#endregion #endregion
} }

View File

@ -2797,6 +2797,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTE
-- The model response used an unsupported contract version. Please try again or select another model. -- The model response used an unsupported contract version. Please try again or select another model.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T921285247"] = "The model response used an unsupported contract version. Please try again or select another model." UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T921285247"] = "The model response used an unsupported contract version. Please try again or select another model."
-- This chart cannot be displayed: {0}
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHARTBLOCK::T1070038198"] = "This chart cannot be displayed: {0}"
-- System -- System
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System" UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System"

View File

@ -0,0 +1,61 @@
@namespace AIStudio.Chat
@using MudBlazor
@inherits AIStudio.Components.MSGComponentBase
@if (this.Result.Chart is { } chart)
{
<div class="chart-block-shell my-3">
<MudPaper Class="chart-block" Outlined="true">
<MudText Typo="Typo.h6" Class="chart-block-title">@chart.Title</MudText>
@if (chart.Type is ChartDefinitionType.TIME_SERIES)
{
<div class="chart-block-plot chart-block-plot-axis">
<MudTimeSeriesChart ChartSeries="@this.TimeSeriesChartSeries"
ChartOptions="@this.ChartOptions"
AxisChartOptions="@this.AxisChartOptions"
TimeLabelSpacing="@this.TimeLabelSpacing"
TimeLabelFormat="@this.TimeLabelFormat"
DataMarkerTooltipTimeLabelFormat="dd-MM-yyyy HH:mm:ss 'UTC'"
Width="100%"
Height="350px" />
</div>
}
else if (chart.Type is ChartDefinitionType.PIE or ChartDefinitionType.DONUT)
{
<div class="chart-block-plot chart-block-plot-circular">
<MudChart ChartType="@this.ChartType"
InputData="@chart.Series[0].Values.ToArray()"
InputLabels="@chart.Categories.ToArray()"
ChartOptions="@this.ChartOptions"
Width="100%"
Height="200px" />
</div>
}
else
{
<div class="chart-block-plot chart-block-plot-axis">
<MudChart ChartType="@this.ChartType"
ChartSeries="@this.ChartSeries"
XAxisLabels="@chart.Categories.ToArray()"
ChartOptions="@this.CategoryChartOptions"
AxisChartOptions="@this.AxisChartOptions"
Width="100%"
Height="350px" />
</div>
}
@if (chart.Caption is not null)
{
<MudText Typo="Typo.caption" Align="Align.Center" Class="chart-block-caption d-block">
<em>@chart.Caption</em>
</MudText>
}
</MudPaper>
</div>
}
else
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="my-3">
@string.Format(T("This chart cannot be displayed: {0}"), this.Result.Error)
</MudAlert>
<pre class="overflow-auto"><code>@this.Result.RawJson</code></pre>
}

View File

@ -0,0 +1,99 @@
using System.Globalization;
using AIStudio.Components;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Chat;
public partial class ChartBlock : MSGComponentBase
{
private AxisChartOptions AxisChartOptions { get; } = new() { MatchBoundsToSize = true };
private ChartOptions ChartOptions { get; } = new()
{
ChartPalette =
[
"#236A50", "#F2D264", "#79AE90", "#C97857", "#4E7894", "#9B6B8F",
"#6A233D", "#6484F2", "#AE7997", "#57A8C9", "#946A4E", "#6B9B77",
],
};
private ChartOptions HeatMapChartOptions { get; } = new()
{
ChartPalette = ["#236A50", "#79AE90", "#F2D264", "#C97857", "#6A233D"],
EnableSmoothGradient = true,
YAxisLabelPosition = YAxisLabelPosition.Right,
};
[Parameter]
public ChartBlockParseResult Result { get; set; } = ChartBlockParseResult.Invalid(string.Empty, string.Empty);
private ChartType ChartType => this.Result.Chart?.Type switch
{
ChartDefinitionType.BAR => ChartType.Bar,
ChartDefinitionType.STACKED_BAR => ChartType.StackedBar,
ChartDefinitionType.LINE => ChartType.Line,
ChartDefinitionType.PIE => ChartType.Pie,
ChartDefinitionType.DONUT => ChartType.Donut,
ChartDefinitionType.HEATMAP => ChartType.HeatMap,
_ => ChartType.Bar,
};
private List<ChartSeries> ChartSeries => this.Result.Chart?.Series
.Select(series => new ChartSeries { Name = series.Name, Data = series.Values.ToArray() })
.ToList() ?? [];
private ChartOptions CategoryChartOptions => this.Result.Chart?.Type is ChartDefinitionType.HEATMAP
? this.HeatMapChartOptions
: this.ChartOptions;
private List<TimeSeriesChartSeries> TimeSeriesChartSeries => this.Result.Chart is not { } chart
? []
: chart.Series
.Select(series => new TimeSeriesChartSeries
{
Name = series.Name,
Data = chart.Categories
.Select((category, index) => new TimeSeriesChartSeries.TimeValue(
DateTimeOffset.Parse(category, CultureInfo.InvariantCulture).UtcDateTime,
series.Values[index]))
.ToList(),
IsVisible = true,
})
.ToList();
private TimeSpan TimeLabelSpacing
{
get
{
var timestamps = this.GetTimeSeriesTimestamps();
if (timestamps.Count < 2)
return TimeSpan.FromSeconds(1);
var range = timestamps[^1] - timestamps[0];
var intervalCount = Math.Min(timestamps.Count - 1, 8);
return TimeSpan.FromTicks(Math.Max(TimeSpan.TicksPerSecond, range.Ticks / intervalCount));
}
}
private string TimeLabelFormat
{
get
{
var timestamps = this.GetTimeSeriesTimestamps();
if (timestamps.Count < 2)
return "yyyy-MM-dd HH:mm";
var range = timestamps[^1] - timestamps[0];
if (range <= TimeSpan.FromDays(2))
return "MM-dd HH:mm";
return range <= TimeSpan.FromDays(730) ? "yyyy-MM-dd" : "yyyy";
}
}
private List<DateTimeOffset> GetTimeSeriesTimestamps() => this.Result.Chart?.Categories
.Select(category => DateTimeOffset.Parse(category, CultureInfo.InvariantCulture).ToUniversalTime())
.ToList() ?? [];
}

View File

@ -0,0 +1,55 @@
.chart-block-shell {
--chart-pine: #236a50;
--chart-sage: #79ae90;
--chart-sun: #f2d264;
--chart-mist: #eaf1ec;
padding: 0 1.5rem;
}
.chart-block-shell ::deep .chart-block {
position: relative;
overflow: hidden;
padding: clamp(1rem, 2.5vw, 1.5rem);
border-color: color-mix(in srgb, var(--mud-palette-lines-default) 85%, var(--chart-pine));
border-radius: 1.25rem;
background: var(--mud-palette-surface);
background: color-mix(in srgb, var(--mud-palette-surface) 96%, var(--chart-mist));
box-shadow: 0 18px 55px rgba(22, 75, 59, .07);
}
.chart-block-shell ::deep .chart-block::before {
position: absolute;
inset: 0 0 auto;
height: .25rem;
background: linear-gradient(90deg, var(--chart-pine), var(--chart-sage), var(--chart-sun));
content: "";
}
.chart-block-shell ::deep .chart-block-title {
margin-block-end: 1rem;
font-weight: 700;
letter-spacing: -.02em;
}
.chart-block-plot {
width: 100%;
margin-inline: auto;
}
.chart-block-plot-circular {
max-width: 30rem;
}
.chart-block-plot-axis {
max-width: 56rem;
}
.chart-block-plot-heatmap {
max-width: none;
}
.chart-block-shell ::deep .chart-block-caption {
margin-block-start: .75rem;
color: var(--mud-palette-text-secondary);
line-height: 1.5;
}

View File

@ -0,0 +1,315 @@
using System.Text;
using System.Text.Json;
namespace AIStudio.Chat;
/// <summary>
/// Parses and validates versioned AI Studio chart blocks without executing their content.
/// </summary>
public static class ChartBlockParser
{
private const int MAX_JSON_BYTES = 32 * 1024;
private const int MAX_CATEGORIES = 50;
private const int MAX_SERIES = 10;
private static readonly HashSet<string> ROOT_PROPERTIES = ["schema_version", "type", "title", "caption", "data"];
private static readonly HashSet<string> DATA_PROPERTIES = ["categories", "series"];
private static readonly HashSet<string> SERIES_PROPERTIES = ["name", "values"];
/// <summary>
/// Parses a JSON chart definition and applies the complete local version 1 validation contract.
/// </summary>
public static ChartBlockParseResult Parse(string json)
{
if (Encoding.UTF8.GetByteCount(json) > MAX_JSON_BYTES)
return ChartBlockParseResult.Invalid(json, "The chart JSON exceeds the 32 KB limit.");
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 8,
});
var root = document.RootElement;
if (root.ValueKind is not JsonValueKind.Object)
return ChartBlockParseResult.Invalid(json, "The chart definition must be a JSON object.");
if (!HasOnlyKnownProperties(root, ROOT_PROPERTIES, out var propertyError))
return ChartBlockParseResult.Invalid(json, propertyError);
if (!TryGetRequiredInt(root, "schema_version", out var schemaVersion) || schemaVersion != 1)
return ChartBlockParseResult.Invalid(json, "schema_version must be 1.");
if (!TryGetRequiredString(root, "type", out var typeText)
|| !TryParseType(typeText, out var type))
return ChartBlockParseResult.Invalid(json, "type must be bar, stacked_bar, line, pie, donut, heatmap, or time_series.");
if (!TryGetRequiredString(root, "title", out var title) || string.IsNullOrWhiteSpace(title))
return ChartBlockParseResult.Invalid(json, "title must be a non-empty string.");
string? caption = null;
if (root.TryGetProperty("caption", out var captionElement))
{
if (captionElement.ValueKind is not JsonValueKind.String
|| string.IsNullOrWhiteSpace(captionElement.GetString()))
return ChartBlockParseResult.Invalid(json, "caption must be a non-empty string when provided.");
caption = captionElement.GetString();
}
if (!root.TryGetProperty("data", out var data) || data.ValueKind is not JsonValueKind.Object)
return ChartBlockParseResult.Invalid(json, "data must be a JSON object.");
if (!HasOnlyKnownProperties(data, DATA_PROPERTIES, out propertyError))
return ChartBlockParseResult.Invalid(json, propertyError);
if (!TryReadCategories(data, type, out var categories, out var error))
return ChartBlockParseResult.Invalid(json, error);
if (!TryReadSeries(data, categories.Count, out var series, out error))
return ChartBlockParseResult.Invalid(json, error);
if (type is ChartDefinitionType.PIE or ChartDefinitionType.DONUT)
{
if (series.Count != 1)
return ChartBlockParseResult.Invalid(json, "Pie and donut charts require exactly one series.");
if (series[0].Values.Any(value => value < 0))
return ChartBlockParseResult.Invalid(json, "Pie and donut chart values must not be negative.");
}
return ChartBlockParseResult.Valid(json, new(schemaVersion, type, title, caption, categories, series));
}
catch (JsonException exception)
{
return ChartBlockParseResult.Invalid(json, $"The chart JSON is invalid: {exception.Message}");
}
}
private static bool TryReadCategories(
JsonElement data,
ChartDefinitionType type,
out IReadOnlyList<string> categories,
out string error)
{
categories = [];
error = string.Empty;
if (!data.TryGetProperty("categories", out var element) || element.ValueKind is not JsonValueKind.Array)
{
error = "data.categories must be an array.";
return false;
}
var values = new List<string>();
DateTimeOffset? previousTimestamp = null;
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind is not JsonValueKind.String || string.IsNullOrWhiteSpace(item.GetString()))
{
error = "Every category must be a non-empty string.";
return false;
}
var value = item.GetString()!;
if (type is ChartDefinitionType.TIME_SERIES)
{
if (!HasExplicitTimeZone(value) || !item.TryGetDateTimeOffset(out var timestamp))
{
error = "Time series categories must be ISO 8601 timestamps with Z or an explicit UTC offset.";
return false;
}
if (previousTimestamp is not null && timestamp <= previousTimestamp.Value)
{
error = "Time series categories must be strictly increasing timestamps.";
return false;
}
previousTimestamp = timestamp;
}
values.Add(value);
if (values.Count > MAX_CATEGORIES)
{
error = $"A chart can contain at most {MAX_CATEGORIES} categories.";
return false;
}
}
if (values.Count == 0)
{
error = "A chart requires at least one category.";
return false;
}
if (type is ChartDefinitionType.TIME_SERIES && values.Count < 2)
{
error = "A time series chart requires at least two timestamps.";
return false;
}
categories = values;
return true;
}
private static bool HasExplicitTimeZone(string value)
{
if (value.EndsWith('Z'))
return true;
if (value.Length < 6)
return false;
var offsetSign = value[^6];
return offsetSign is '+' or '-'
&& char.IsAsciiDigit(value[^5])
&& char.IsAsciiDigit(value[^4])
&& value[^3] is ':'
&& char.IsAsciiDigit(value[^2])
&& char.IsAsciiDigit(value[^1]);
}
private static bool TryReadSeries(JsonElement data, int categoryCount, out IReadOnlyList<ChartDefinitionSeries> series, out string error)
{
series = [];
error = string.Empty;
if (!data.TryGetProperty("series", out var element) || element.ValueKind is not JsonValueKind.Array)
{
error = "data.series must be an array.";
return false;
}
var result = new List<ChartDefinitionSeries>();
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind is not JsonValueKind.Object)
{
error = "Every series must be a JSON object.";
return false;
}
if (!HasOnlyKnownProperties(item, SERIES_PROPERTIES, out error))
return false;
if (!TryGetRequiredString(item, "name", out var name) || string.IsNullOrWhiteSpace(name))
{
error = "Every series requires a non-empty name.";
return false;
}
if (!item.TryGetProperty("values", out var valuesElement) || valuesElement.ValueKind is not JsonValueKind.Array)
{
error = "Every series requires a values array.";
return false;
}
var values = new List<double>();
foreach (var valueElement in valuesElement.EnumerateArray())
{
if (valueElement.ValueKind is not JsonValueKind.Number
|| !valueElement.TryGetDouble(out var value)
|| !double.IsFinite(value))
{
error = "Series values must be finite JSON numbers.";
return false;
}
values.Add(value);
}
if (values.Count != categoryCount)
{
error = "Every series must contain exactly one value per category.";
return false;
}
result.Add(new(name, values));
if (result.Count > MAX_SERIES)
{
error = $"A chart can contain at most {MAX_SERIES} series.";
return false;
}
}
if (result.Count == 0)
{
error = "A chart requires at least one series.";
return false;
}
series = result;
return true;
}
private static bool HasOnlyKnownProperties(JsonElement element, HashSet<string> knownProperties, out string error)
{
var seenProperties = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (!knownProperties.Contains(property.Name))
{
error = $"Unknown chart property: {property.Name}.";
return false;
}
if (!seenProperties.Add(property.Name))
{
error = $"Duplicate chart property: {property.Name}.";
return false;
}
}
error = string.Empty;
return true;
}
private static bool TryGetRequiredInt(JsonElement element, string propertyName, out int value)
{
value = 0;
return element.TryGetProperty(propertyName, out var property)
&& property.ValueKind is JsonValueKind.Number
&& property.TryGetInt32(out value);
}
private static bool TryGetRequiredString(JsonElement element, string propertyName, out string value)
{
value = string.Empty;
if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind is not JsonValueKind.String)
return false;
value = property.GetString() ?? string.Empty;
return true;
}
private static bool TryParseType(string value, out ChartDefinitionType type)
{
type = value switch
{
"bar" => ChartDefinitionType.BAR,
"stacked_bar" => ChartDefinitionType.STACKED_BAR,
"line" => ChartDefinitionType.LINE,
"pie" => ChartDefinitionType.PIE,
"donut" => ChartDefinitionType.DONUT,
"heatmap" => ChartDefinitionType.HEATMAP,
"time_series" => ChartDefinitionType.TIME_SERIES,
_ => default,
};
return value is "bar" or "stacked_bar" or "line" or "pie" or "donut" or "heatmap" or "time_series";
}
}
/// <summary>
/// The safe result of parsing a chart block, including the original JSON for fallback display.
/// </summary>
public sealed record ChartBlockParseResult(string RawJson, ChartDefinition? Chart, string Error)
{
public bool IsValid => this.Chart is not null;
public static ChartBlockParseResult Valid(string rawJson, ChartDefinition chart) => new(rawJson, chart, string.Empty);
public static ChartBlockParseResult Invalid(string rawJson, string error) => new(rawJson, null, error);
}

View File

@ -0,0 +1,31 @@
namespace AIStudio.Chat;
/// <summary>
/// A renderer-independent, validated chart definition produced by an AI response.
/// </summary>
public sealed record ChartDefinition(
int SchemaVersion,
ChartDefinitionType Type,
string Title,
string? Caption,
IReadOnlyList<string> Categories,
IReadOnlyList<ChartDefinitionSeries> Series);
/// <summary>
/// A named series in a chart definition.
/// </summary>
public sealed record ChartDefinitionSeries(string Name, IReadOnlyList<double> Values);
/// <summary>
/// Chart types supported by version 1 of the AI Studio chart contract.
/// </summary>
public enum ChartDefinitionType
{
BAR,
STACKED_BAR,
LINE,
PIE,
DONUT,
HEATMAP,
TIME_SERIES,
}

View File

@ -13,6 +13,36 @@ namespace AIStudio.Chat;
public sealed record ChatThread public sealed record ChatThread
{ {
private static readonly ILogger<ChatThread> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ChatThread>(); private static readonly ILogger<ChatThread> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ChatThread>();
private const string CHART_OUTPUT_INSTRUCTIONS = """
When a chart is useful or explicitly requested, you may include one or more complete chart blocks in your normal response. Use exactly this fenced JSON format:
```aistudio-chart
{
"schema_version": 1,
"type": "bar",
"title": "Chart title",
"caption": "Contextual interpretation of the chart",
"data": {
"categories": ["A", "B"],
"series": [
{
"name": "Series",
"values": [1, 2]
}
]
}
}
```
- Supported types are `bar`, `stacked_bar`, `line`, `pie`, `donut`, `heatmap`, and `time_series`.
- Every series needs exactly one finite numeric value per category.
- Pie and donut charts need exactly one series and non-negative values.
- For heatmaps, categories are the columns, series names are the rows, and series values are the cells.
- Time series categories must contain at least two strictly increasing ISO 8601 timestamps with `Z` or an explicit UTC offset.
- Add a concise caption that correctly contextualizes the chart and may explain the chart's main finding.
- Use only the fields shown above.
- Keep other explanatory text outside the chart block.
""";
/// <summary> /// <summary>
/// The unique identifier of the chat thread. /// The unique identifier of the chat thread.
@ -56,6 +86,12 @@ public sealed record ChatThread
/// </summary> /// </summary>
public bool IncludeDateTime { get; set; } = false; public bool IncludeDateTime { get; set; } = false;
/// <summary>
/// Indicates whether the model may emit locally rendered chart blocks.
/// False by default so internal structured model requests remain unchanged.
/// </summary>
public bool AllowChartOutput { get; set; } = false;
/// <summary> /// <summary>
/// The data source options for this chat thread. /// The data source options for this chat thread.
/// </summary> /// </summary>
@ -198,23 +234,32 @@ public sealed record ChatThread
} }
LOGGER.LogInformation(logMessage); LOGGER.LogInformation(logMessage);
if(!this.IncludeDateTime) if(this.IncludeDateTime)
{
//
// Prepend the current date and time to the system prompt:
//
var nowUtc = DateTime.UtcNow;
var nowLocal = DateTime.Now;
var currentDateTime = string.Create(
new CultureInfo("en-US"),
$"Today is {nowUtc:dddd, MMMM d, yyyy h:mm tt} (UTC) and {nowLocal:dddd, MMMM d, yyyy h:mm tt} (local time)."
);
systemPromptText = $"""
{currentDateTime}
{systemPromptText}
""";
}
if (!this.AllowChartOutput)
return systemPromptText; return systemPromptText;
//
// Prepend the current date and time to the system prompt:
//
var nowUtc = DateTime.UtcNow;
var nowLocal = DateTime.Now;
var currentDateTime = string.Create(
new CultureInfo("en-US"),
$"Today is {nowUtc:dddd, MMMM d, yyyy h:mm tt} (UTC) and {nowLocal:dddd, MMMM d, yyyy h:mm tt} (local time)."
);
return $""" return $"""
{currentDateTime}
{systemPromptText} {systemPromptText}
{CHART_OUTPUT_INSTRUCTIONS}
"""; """;
} }
@ -314,4 +359,4 @@ public sealed record ChatThread
return new Tools.ERIClient.DataModel.ChatThread { ContentBlocks = contentBlocks }; return new Tools.ERIClient.DataModel.ChatThread { ContentBlocks = contentBlocks };
} }
} }

View File

@ -105,10 +105,14 @@
{ {
<MudMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" /> <MudMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" />
} }
else else if (segment.Type is MarkdownRenderSegmentType.MATH_BLOCK)
{ {
<MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" /> <MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" />
} }
else if (segment.ChartResult is not null)
{
<ChartBlock @key="@segment.RenderKey" Result="@segment.ChartResult" />
}
} }
@if (textContent.Sources.Count > 0) @if (textContent.Sources.Count > 0)
{ {

View File

@ -17,6 +17,7 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
private const string HTML_SELF_CLOSING_TAG = "/>"; private const string HTML_SELF_CLOSING_TAG = "/>";
private const string CODE_FENCE_MARKER_BACKTICK = "```"; private const string CODE_FENCE_MARKER_BACKTICK = "```";
private const string CODE_FENCE_MARKER_TILDE = "~~~"; private const string CODE_FENCE_MARKER_TILDE = "~~~";
private const string CHART_CODE_FENCE_LANGUAGE = "aistudio-chart";
private const string MATH_BLOCK_MARKER_DOLLAR = "$$"; private const string MATH_BLOCK_MARKER_DOLLAR = "$$";
private const string MATH_BLOCK_MARKER_BRACKET_OPEN = """\["""; private const string MATH_BLOCK_MARKER_BRACKET_OPEN = """\[""";
private const string MATH_BLOCK_MARKER_BRACKET_CLOSE = """\]"""; private const string MATH_BLOCK_MARKER_BRACKET_CLOSE = """\]""";
@ -336,6 +337,20 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
} }
var trimmedLine = TrimWhitespace(normalizedSpan[lineStart..lineEnd]); var trimmedLine = TrimWhitespace(normalizedSpan[lineStart..lineEnd]);
if (activeMathBlockFenceType is MathBlockFenceType.NONE
&& activeCodeFenceMarker == '\0'
&& TryGetChartFenceMarker(trimmedLine, out var chartFenceMarker)
&& TryFindClosingCodeFence(normalizedSpan, nextLineStart, chartFenceMarker, out var chartContentEnd, out var afterChartFence))
{
AddMarkdownSegment(markdownSegmentStart, lineStart);
var (start, end) = TrimLineBreaks(normalizedSpan, nextLineStart, chartContentEnd);
var chartJson = normalized.Substring(start, end - start);
segments.Add(new(MarkdownRenderSegmentType.CHART, start, end - start, ChartBlockParser.Parse(chartJson)));
markdownSegmentStart = afterChartFence;
lineStart = afterChartFence;
continue;
}
if (activeMathBlockFenceType is MathBlockFenceType.NONE && TryUpdateCodeFenceState(trimmedLine, ref activeCodeFenceMarker)) if (activeMathBlockFenceType is MathBlockFenceType.NONE && TryUpdateCodeFenceState(trimmedLine, ref activeCodeFenceMarker))
{ {
lineStart = nextLineStart; lineStart = nextLineStart;
@ -463,6 +478,48 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
return true; return true;
} }
private static bool TryGetChartFenceMarker(ReadOnlySpan<char> trimmedLine, out char fenceMarker)
{
fenceMarker = '\0';
if (trimmedLine.SequenceEqual($"{CODE_FENCE_MARKER_BACKTICK}{CHART_CODE_FENCE_LANGUAGE}".AsSpan()))
fenceMarker = '`';
else if (trimmedLine.SequenceEqual($"{CODE_FENCE_MARKER_TILDE}{CHART_CODE_FENCE_LANGUAGE}".AsSpan()))
fenceMarker = '~';
return fenceMarker != '\0';
}
private static bool TryFindClosingCodeFence(ReadOnlySpan<char> text, int searchStart, char fenceMarker, out int contentEnd, out int afterFence)
{
contentEnd = 0;
afterFence = 0;
var closingFence = fenceMarker == '`' ? CODE_FENCE_MARKER_BACKTICK : CODE_FENCE_MARKER_TILDE;
for (var lineStart = searchStart; lineStart < text.Length;)
{
var lineEnd = lineStart;
while (lineEnd < text.Length && text[lineEnd] is not '\r' and not '\n')
lineEnd++;
var nextLineStart = lineEnd;
if (nextLineStart < text.Length && text[nextLineStart] == '\r')
nextLineStart++;
if (nextLineStart < text.Length && text[nextLineStart] == '\n')
nextLineStart++;
if (TrimWhitespace(text[lineStart..lineEnd]).SequenceEqual(closingFence.AsSpan()))
{
contentEnd = lineStart;
afterFence = nextLineStart;
return true;
}
lineStart = nextLineStart;
}
return false;
}
private static ReadOnlySpan<char> TrimWhitespace(ReadOnlySpan<char> text) private static ReadOnlySpan<char> TrimWhitespace(ReadOnlySpan<char> text)
{ {
var start = 0; var start = 0;
@ -492,6 +549,7 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
{ {
MARKDOWN, MARKDOWN,
MATH_BLOCK, MATH_BLOCK,
CHART,
} }
private enum MathBlockFenceType private enum MathBlockFenceType
@ -506,7 +564,7 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
public static readonly MarkdownRenderPlan EMPTY = new(string.Empty, []); public static readonly MarkdownRenderPlan EMPTY = new(string.Empty, []);
} }
private sealed class MarkdownRenderSegment(MarkdownRenderSegmentType type, int start, int length) private sealed class MarkdownRenderSegment(MarkdownRenderSegmentType type, int start, int length, ChartBlockParseResult? chartResult = null)
{ {
private string? cachedContent; private string? cachedContent;
@ -516,6 +574,8 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
public int Length { get; } = length; public int Length { get; } = length;
public ChartBlockParseResult? ChartResult { get; } = chartResult;
public int RenderKey { get; } = HashCode.Combine(type, start, length); public int RenderKey { get; } = HashCode.Combine(type, start, length);
public string GetContent(string source) public string GetContent(string source)

View File

@ -817,6 +817,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// //
} }
// This is a user-visible chat request. Internal structured LLM requests keep the default disabled.
this.ChatThread.AllowChartOutput = true;
var time = DateTimeOffset.Now; var time = DateTimeOffset.Now;
IContent? lastUserPrompt; IContent? lastUserPrompt;
if (!reuseLastUserPrompt) if (!reuseLastUserPrompt)
@ -1304,4 +1307,4 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
} }
#endregion #endregion
} }

View File

@ -87,6 +87,40 @@ Each assistant plugin lives in its own directory under the assistants plugin roo
- `LaunchBehavior = "OPEN_WORKSPACE_CHAT_BY_NAME"` - `LaunchBehavior = "OPEN_WORKSPACE_CHAT_BY_NAME"`
- `WorkspaceName = "<target workspace name>"` - `WorkspaceName = "<target workspace name>"`
- `UI.Type` is always `"FORM"` and `UI.Children` is a list of component tables. - `UI.Type` is always `"FORM"` and `UI.Children` is a list of component tables.
### Chart output
When an assistant is expected to return a chart, include the following instructions in `ASSISTANT.SystemPrompt`. AI Studio validates and renders the block locally; Lua does not generate or render the chart itself.
````text
When a chart is useful or explicitly requested, you may include one or more complete chart blocks in your normal response. Use exactly this fenced JSON format:
```aistudio-chart
{
"schema_version": 1,
"type": "bar",
"title": "Chart title",
"caption": "Contextual interpretation of the chart",
"data": {
"categories": ["A", "B"],
"series": [
{
"name": "Series",
"values": [1, 2]
}
]
}
}
```
- Supported types are `bar`, `stacked_bar`, `line`, `pie`, `donut`, `heatmap`, and `time_series`.
- Every series needs exactly one finite numeric value per category.
- Pie and donut charts need exactly one series and non-negative values.
- For heatmaps, categories are the columns, series names are the rows, and series values are the cells.
- Time series categories must contain at least two strictly increasing ISO 8601 timestamps with `Z` or an explicit UTC offset.
- Add a concise caption that correctly contextualizes the chart and may explain the chart's main finding.
- Use only the fields shown above.
- Keep other explanatory text outside the chart block.
````
- Each component table declares `Type`, an optional `Children` array, and a `Props` table that feeds the components parameters. - Each component table declares `Type`, an optional `Children` array, and a `Props` table that feeds the components parameters.
### Example: Minimal Requirements Assistant Table ### Example: Minimal Requirements Assistant Table
@ -96,7 +130,7 @@ DEPLOYED_USING_CONFIG_SERVER = false
ASSISTANT = { ASSISTANT = {
["Title"] = "", ["Title"] = "",
["Description"] = "", ["Description"] = "",
["SystemPrompt"] = "", ["SystemPrompt"] = "", -- request an aistudio-chart block here when the expected result is a chart
["SubmitText"] = "", ["SubmitText"] = "",
["AllowProfiles"] = true, ["AllowProfiles"] = true,
["LaunchBehavior"] = "OPEN_WORKSPACE_CHAT_BY_NAME", ["LaunchBehavior"] = "OPEN_WORKSPACE_CHAT_BY_NAME",

View File

@ -63,11 +63,41 @@ ASSISTANT = {
}, },
} }
--[[
When a chart is useful or explicitly requested, you may include one or more complete chart blocks in your normal response. Use exactly this fenced JSON format:
```aistudio-chart
{
"schema_version": 1,
"type": "bar",
"title": "Chart title",
"caption": "Contextual interpretation of the chart",
"data": {
"categories": ["A", "B"],
"series": [
{
"name": "Series",
"values": [1, 2]
}
]
}
}
```
- Supported types are `bar`, `stacked_bar`, `line`, `pie`, `donut`, `heatmap`, and `time_series`.
- Every series needs exactly one finite numeric value per category.
- Pie and donut charts need exactly one series and non-negative values.
- For heatmaps, categories are the columns, series names are the rows, and series values are the cells.
- Time series categories must contain at least two strictly increasing ISO 8601 timestamps with `Z` or an explicit UTC offset.
- Add a concise caption that correctly contextualizes the chart and may explain the chart's main finding.
- Use only the fields shown above.
- Keep other explanatory text outside the chart block.
]]
-- usage example with the full feature set: -- usage example with the full feature set:
ASSISTANT = { ASSISTANT = {
["Title"] = "<main title of assistant>", -- required ["Title"] = "<main title of assistant>", -- required
["Description"] = "<assistant description>", -- required ["Description"] = "<assistant description>", -- required
["SystemPrompt"] = "<prompt that fundamentally changes behaviour, personality and task focus of your assistant. Invisible to the user>", -- required ["SystemPrompt"] = "<prompt that fundamentally changes behaviour, personality and task focus of your assistant. If the expected result is a chart, include the chart-output instructions above. Invisible to the user>", -- required
["SubmitText"] = "<label for submit button>", -- required ["SubmitText"] = "<label for submit button>", -- required
["AllowProfiles"] = true, -- if true, allows AiStudios profiles; required ["AllowProfiles"] = true, -- if true, allows AiStudios profiles; required
["LaunchBehavior"] = "<NONE|OPEN_WORKSPACE_CHAT_BY_NAME>", -- optional; when set to OPEN_WORKSPACE_CHAT_BY_NAME the tile opens a chat directly ["LaunchBehavior"] = "<NONE|OPEN_WORKSPACE_CHAT_BY_NAME>", -- optional; when set to OPEN_WORKSPACE_CHAT_BY_NAME the tile opens a chat directly

View File

@ -2799,6 +2799,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTE
-- The model response used an unsupported contract version. Please try again or select another model. -- The model response used an unsupported contract version. Please try again or select another model.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T921285247"] = "Die Modellantwort verwendet eine nicht unterstützte Vertragsversion. Bitte versuchen Sie es erneut oder wählen Sie ein anderes Modell aus." UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T921285247"] = "Die Modellantwort verwendet eine nicht unterstützte Vertragsversion. Bitte versuchen Sie es erneut oder wählen Sie ein anderes Modell aus."
-- This chart cannot be displayed: {0}
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHARTBLOCK::T1070038198"] = "Dieses Diagramm kann nicht angezeigt werden: {0}"
-- System -- System
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System" UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System"

View File

@ -2799,6 +2799,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTE
-- The model response used an unsupported contract version. Please try again or select another model. -- The model response used an unsupported contract version. Please try again or select another model.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T921285247"] = "The model response used an unsupported contract version. Please try again or select another model." UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T921285247"] = "The model response used an unsupported contract version. Please try again or select another model."
-- This chart cannot be displayed: {0}
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHARTBLOCK::T1070038198"] = "This chart cannot be displayed: {0}"
-- System -- System
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System" UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System"