mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 16:32:10 +00:00
Merge 319ca8806e into b9ec13edcf
This commit is contained in:
commit
f08ade315d
@ -339,6 +339,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
this.ChatThread = new()
|
||||
{
|
||||
IncludeDateTime = false,
|
||||
AllowChartOutput = true,
|
||||
SelectedProvider = this.ProviderSettings.Id,
|
||||
SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id,
|
||||
SystemPrompt = this.SystemPrompt,
|
||||
@ -355,6 +356,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
this.ChatThread = new()
|
||||
{
|
||||
IncludeDateTime = false,
|
||||
AllowChartOutput = true,
|
||||
SelectedProvider = this.ProviderSettings.Id,
|
||||
SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id,
|
||||
SystemPrompt = this.SystemPrompt,
|
||||
@ -922,4 +924,4 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
protected virtual void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) { }
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
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
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System"
|
||||
|
||||
|
||||
61
app/MindWork AI Studio/Chat/ChartBlock.razor
Normal file
61
app/MindWork AI Studio/Chat/ChartBlock.razor
Normal 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>
|
||||
}
|
||||
99
app/MindWork AI Studio/Chat/ChartBlock.razor.cs
Normal file
99
app/MindWork AI Studio/Chat/ChartBlock.razor.cs
Normal 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() ?? [];
|
||||
}
|
||||
55
app/MindWork AI Studio/Chat/ChartBlock.razor.css
Normal file
55
app/MindWork AI Studio/Chat/ChartBlock.razor.css
Normal 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;
|
||||
}
|
||||
315
app/MindWork AI Studio/Chat/ChartBlockParser.cs
Normal file
315
app/MindWork AI Studio/Chat/ChartBlockParser.cs
Normal 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);
|
||||
}
|
||||
31
app/MindWork AI Studio/Chat/ChartDefinition.cs
Normal file
31
app/MindWork AI Studio/Chat/ChartDefinition.cs
Normal 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,
|
||||
}
|
||||
@ -13,6 +13,36 @@ namespace AIStudio.Chat;
|
||||
public sealed record 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>
|
||||
/// The unique identifier of the chat thread.
|
||||
@ -56,6 +86,12 @@ public sealed record ChatThread
|
||||
/// </summary>
|
||||
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>
|
||||
/// The data source options for this chat thread.
|
||||
/// </summary>
|
||||
@ -198,23 +234,32 @@ public sealed record ChatThread
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
//
|
||||
// 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 $"""
|
||||
{currentDateTime}
|
||||
|
||||
{systemPromptText}
|
||||
|
||||
{CHART_OUTPUT_INSTRUCTIONS}
|
||||
""";
|
||||
}
|
||||
|
||||
@ -314,4 +359,4 @@ public sealed record ChatThread
|
||||
|
||||
return new Tools.ERIClient.DataModel.ChatThread { ContentBlocks = contentBlocks };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,10 +105,14 @@
|
||||
{
|
||||
<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" />
|
||||
}
|
||||
else if (segment.ChartResult is not null)
|
||||
{
|
||||
<ChartBlock @key="@segment.RenderKey" Result="@segment.ChartResult" />
|
||||
}
|
||||
}
|
||||
@if (textContent.Sources.Count > 0)
|
||||
{
|
||||
|
||||
@ -17,6 +17,7 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
private const string HTML_SELF_CLOSING_TAG = "/>";
|
||||
private const string CODE_FENCE_MARKER_BACKTICK = "```";
|
||||
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_BRACKET_OPEN = """\[""";
|
||||
private const string MATH_BLOCK_MARKER_BRACKET_CLOSE = """\]""";
|
||||
@ -336,6 +337,20 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
lineStart = nextLineStart;
|
||||
@ -463,6 +478,48 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
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)
|
||||
{
|
||||
var start = 0;
|
||||
@ -492,6 +549,7 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
{
|
||||
MARKDOWN,
|
||||
MATH_BLOCK,
|
||||
CHART,
|
||||
}
|
||||
|
||||
private enum MathBlockFenceType
|
||||
@ -506,7 +564,7 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
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;
|
||||
|
||||
@ -516,6 +574,8 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
|
||||
public int Length { get; } = length;
|
||||
|
||||
public ChartBlockParseResult? ChartResult { get; } = chartResult;
|
||||
|
||||
public int RenderKey { get; } = HashCode.Combine(type, start, length);
|
||||
|
||||
public string GetContent(string source)
|
||||
|
||||
@ -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;
|
||||
IContent? lastUserPrompt;
|
||||
if (!reuseLastUserPrompt)
|
||||
@ -1304,4 +1307,4 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,40 @@ Each assistant plugin lives in its own directory under the assistants plugin roo
|
||||
- `LaunchBehavior = "OPEN_WORKSPACE_CHAT_BY_NAME"`
|
||||
- `WorkspaceName = "<target workspace name>"`
|
||||
- `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 component’s parameters.
|
||||
|
||||
### Example: Minimal Requirements Assistant Table
|
||||
@ -96,7 +130,7 @@ DEPLOYED_USING_CONFIG_SERVER = false
|
||||
ASSISTANT = {
|
||||
["Title"] = "",
|
||||
["Description"] = "",
|
||||
["SystemPrompt"] = "",
|
||||
["SystemPrompt"] = "", -- request an aistudio-chart block here when the expected result is a chart
|
||||
["SubmitText"] = "",
|
||||
["AllowProfiles"] = true,
|
||||
["LaunchBehavior"] = "OPEN_WORKSPACE_CHAT_BY_NAME",
|
||||
|
||||
@ -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:
|
||||
ASSISTANT = {
|
||||
["Title"] = "<main title of assistant>", -- 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
|
||||
["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
|
||||
|
||||
@ -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.
|
||||
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
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System"
|
||||
|
||||
|
||||
@ -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.
|
||||
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
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CHATROLEEXTENSIONS::T2402387132"] = "System"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user