diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 395f8055..5746ff62 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -339,6 +339,7 @@ public abstract partial class AssistantBase : 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 : 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 : AssistantLowerBase wher protected virtual void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) { } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index de755bd7..150a0466 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -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" diff --git a/app/MindWork AI Studio/Chat/ChartBlock.razor b/app/MindWork AI Studio/Chat/ChartBlock.razor new file mode 100644 index 00000000..5dc94672 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChartBlock.razor @@ -0,0 +1,61 @@ +@namespace AIStudio.Chat +@using MudBlazor +@inherits AIStudio.Components.MSGComponentBase + +@if (this.Result.Chart is { } chart) +{ +
+ + @chart.Title + @if (chart.Type is ChartDefinitionType.TIME_SERIES) + { +
+ +
+ } + else if (chart.Type is ChartDefinitionType.PIE or ChartDefinitionType.DONUT) + { +
+ +
+ } + else + { +
+ +
+ } + @if (chart.Caption is not null) + { + + @chart.Caption + + } +
+
+} +else +{ + + @string.Format(T("This chart cannot be displayed: {0}"), this.Result.Error) + +
@this.Result.RawJson
+} diff --git a/app/MindWork AI Studio/Chat/ChartBlock.razor.cs b/app/MindWork AI Studio/Chat/ChartBlock.razor.cs new file mode 100644 index 00000000..da8e12f8 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChartBlock.razor.cs @@ -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 => 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 => 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 GetTimeSeriesTimestamps() => this.Result.Chart?.Categories + .Select(category => DateTimeOffset.Parse(category, CultureInfo.InvariantCulture).ToUniversalTime()) + .ToList() ?? []; +} diff --git a/app/MindWork AI Studio/Chat/ChartBlock.razor.css b/app/MindWork AI Studio/Chat/ChartBlock.razor.css new file mode 100644 index 00000000..862517d7 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChartBlock.razor.css @@ -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; +} diff --git a/app/MindWork AI Studio/Chat/ChartBlockParser.cs b/app/MindWork AI Studio/Chat/ChartBlockParser.cs new file mode 100644 index 00000000..8376eba5 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChartBlockParser.cs @@ -0,0 +1,315 @@ +using System.Text; +using System.Text.Json; + +namespace AIStudio.Chat; + +/// +/// Parses and validates versioned AI Studio chart blocks without executing their content. +/// +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 ROOT_PROPERTIES = ["schema_version", "type", "title", "caption", "data"]; + private static readonly HashSet DATA_PROPERTIES = ["categories", "series"]; + private static readonly HashSet SERIES_PROPERTIES = ["name", "values"]; + + /// + /// Parses a JSON chart definition and applies the complete local version 1 validation contract. + /// + 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 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(); + 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 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(); + 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(); + 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 knownProperties, out string error) + { + var seenProperties = new HashSet(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"; + } +} + +/// +/// The safe result of parsing a chart block, including the original JSON for fallback display. +/// +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); +} diff --git a/app/MindWork AI Studio/Chat/ChartDefinition.cs b/app/MindWork AI Studio/Chat/ChartDefinition.cs new file mode 100644 index 00000000..f0c52fc9 --- /dev/null +++ b/app/MindWork AI Studio/Chat/ChartDefinition.cs @@ -0,0 +1,31 @@ +namespace AIStudio.Chat; + +/// +/// A renderer-independent, validated chart definition produced by an AI response. +/// +public sealed record ChartDefinition( + int SchemaVersion, + ChartDefinitionType Type, + string Title, + string? Caption, + IReadOnlyList Categories, + IReadOnlyList Series); + +/// +/// A named series in a chart definition. +/// +public sealed record ChartDefinitionSeries(string Name, IReadOnlyList Values); + +/// +/// Chart types supported by version 1 of the AI Studio chart contract. +/// +public enum ChartDefinitionType +{ + BAR, + STACKED_BAR, + LINE, + PIE, + DONUT, + HEATMAP, + TIME_SERIES, +} diff --git a/app/MindWork AI Studio/Chat/ChatThread.cs b/app/MindWork AI Studio/Chat/ChatThread.cs index 3b00805a..8965c37f 100644 --- a/app/MindWork AI Studio/Chat/ChatThread.cs +++ b/app/MindWork AI Studio/Chat/ChatThread.cs @@ -13,6 +13,36 @@ namespace AIStudio.Chat; public sealed record ChatThread { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + + 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. + """; /// /// The unique identifier of the chat thread. @@ -56,6 +86,12 @@ public sealed record ChatThread /// public bool IncludeDateTime { get; set; } = false; + /// + /// Indicates whether the model may emit locally rendered chart blocks. + /// False by default so internal structured model requests remain unchanged. + /// + public bool AllowChartOutput { get; set; } = false; + /// /// The data source options for this chat thread. /// @@ -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 }; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index 52999549..652dac14 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -105,10 +105,14 @@ { } - else + else if (segment.Type is MarkdownRenderSegmentType.MATH_BLOCK) { } + else if (segment.ChartResult is not null) + { + + } } @if (textContent.Sources.Count > 0) { diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index 0dcb910c..ee173170 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -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 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 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 TrimWhitespace(ReadOnlySpan 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) diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 2cee066a..1c90dc64 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -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 -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Plugins/assistants/README.md b/app/MindWork AI Studio/Plugins/assistants/README.md index 78cc762c..320f78b5 100644 --- a/app/MindWork AI Studio/Plugins/assistants/README.md +++ b/app/MindWork AI Studio/Plugins/assistants/README.md @@ -87,6 +87,40 @@ Each assistant plugin lives in its own directory under the assistants plugin roo - `LaunchBehavior = "OPEN_WORKSPACE_CHAT_BY_NAME"` - `WorkspaceName = ""` - `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", diff --git a/app/MindWork AI Studio/Plugins/assistants/plugin.lua b/app/MindWork AI Studio/Plugins/assistants/plugin.lua index ea67d5ef..78d1e7c3 100644 --- a/app/MindWork AI Studio/Plugins/assistants/plugin.lua +++ b/app/MindWork AI Studio/Plugins/assistants/plugin.lua @@ -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"] = "
", -- required ["Description"] = "", -- required - ["SystemPrompt"] = "", -- required + ["SystemPrompt"] = "", -- required ["SubmitText"] = "