diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index ec3b453a4c0..6d09d74a020 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -398,11 +398,34 @@ async IAsyncEnumerable WriteOutput() "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFwAAABcCAMAAADUMSJqAAAAilBMVEX///+XgOV0Vd1RK9S5qu7c1fZHGdKto+hOJ9Oag+ZNJNOWfuVyU91LINPi3Pi4qO6WiuOEbt9EEtJtTdtaNtZnRtn39f1WMdX6+f6ReeTs6fr08fwhAM68sO1iQtjIvvA8ANHl4fhePNeEaeHQx/Kjj+iwoOyeiednUtnCuO+jlOfWzvR5Xd5+Yt+aCjLkAAAD3ElEQVRoge2ZW3eqMBCFC0gM4VIJKlIPQkXbqvj//94hk1a5TChI+uZefes6X2dl78wMOS8vTz31VFPb7R+BN+nXv0pf6UY/295xalWifGfrZmcSDfg808sO7mxR/LtWeMSACj+WxSKd7BMX6DgyzSgWeH7Sx17vBTE2QbH4O/u1NviZCGAk4ZH4Q+Ssi73NxUEvzW8tBT3XdZ0K4Sb1fuBQOgv1sC/g5t68CRzgFy1wGUPvDvf0xTGFwldmTSsoPZ3OXse1GP4I4kinx/FYj2HDU3Kcyn5vu1mL4+QWU/gtN2ue+sU09gEK37XZprmD0g+T4BBpq8s2TeiS+ylTadaNYTOOs8fZa9JoKi1Pxe/I4y1mgcWwEUffeZQdEBlDz+ukRQj8IMGDcA+6YZkkYYnRZYspH2Nnoo0z0zEMw7lidIjjY6vABv7t0gC56LHHD8dxTuASOgB3Qqx0iCP5GM9+kxGX7EqlMo78bTQ8EU2FJT9sAy1dxjEZy7ZzmJu3wg0HTQzEMR+7PJoQQ9e4K1HH0RzHhhWLXZ0a3CmUno5bwGC20b3RUE8c4zFxhBXLD50GHI/j6AWsHUOtcQxFDGnSZuOeyjgOXsBgtsmm0lRfixk48TYQw9jtsHvj6A2Dw2xjRbfw3jjmgybeRm4qGLuSMo4WGRLHT2gqIQ7vi+Pid/Y7zLaV6yYuJqP0upILmP/7xIMVyz8f7MvpFdHsAzUa4Nff2IccZnpg23aQvc4QHbHTKgfFUYSWLm2pFKO/YqUb0GJ2/ew5hz0qkPALWvoZoTtwmnzex94yKu7DN9u2TygdvQFibFDat4AZ0A0z+yaMjXsaQhwNNTvgMBKDO/yAlv6Jle7B2FDHsRRNhdh1pYNLd1nvAgYrFjkHdTju6RHzFEpTLWAbmG07uyk07HgcxT1VTbwP4SZJgxYd9RSPoyidowvYmy9iWLbZCk8djA43kGETD2YbObTZYzxNmGLiBdBU3E7hlafowajjmHfjGIEbly5b5SlmqQHfeJ2JBysWPyOFC08xOhrHK0OeHDbw8hbhbJWn2MFAHK1mHBcyhji7amCD4xiyzgK2piKGhaLwSsNbzIq2X0tgDcJi2OupeuI1HgXE5fRDdeGKe4qWLmLX+E4SLzZEERUp9Cbh45S1Dl2szHIqK+HYuaCVm7QFFym/jWVMaBjR3uha7U8NWMj9wg4Uwk4lnX06Xbny27TRvKBr+XGyQGWEiApsa4wsZFt/g8d3ynxcDBPFBM2l/bKWiYPRI8o7o+6Q+3rYfpddnUzCOZksThL822ubzScr+6v/pnrqqad06z+o/mHi4pLvAgAAAABJRU5ErkJggg==" } ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "Assistant content" + } + ] + }, + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBB Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.\n\nLorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.\n\nLorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.\n\nLorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor." + }, + { + "type": "text", + "content": "# ๐Ÿ“ Markdown Feature Showcase\n\nWelcome to a **comprehensive example** of markdown in action. \nThis document demonstrates *all* the main features.\n\n---\n\n## 1. Headings\n\n# H1 Heading \n## H2 Heading \n### H3 Heading \n#### H4 Heading \n##### H5 Heading \n###### H6 Heading \n\n---\n\n## 2. Emphasis\n\n- *Italic text* \n- **Bold text** \n- ***Bold and italic*** \n- ~~Strikethrough~~ \n- Underlined (via HTML) \n\n---\n\n## 3. Lists\n\n### Unordered list:\n- Item A\n - Sub-item A1\n - Sub-item A2\n- Item B \n- Item C \n\n### Ordered list:\n1. First\n2. Second\n 1. Sub-second\n 2. Sub-second again\n3. Third \n\n### Task list:\n- [x] Done item \n- [ ] Pending item \n- [ ] Another pending item \n\n---\n\n## 4. Links\n\n- Inline link: [OpenAI](https://openai.com) \n- Reference link: [Search Engine][google] \n- Autolink: \n\n[google]: https://google.com \"Google Search\"\n\n---\n\n## 5. Images\n\nInline image: \n![Example](/img/TokenExample.png) \n\nLinked image: \n[![Example](/img/TokenExample.png)](https://openai.com)\n\n---\n\n## 6. Blockquotes\n\n> This is a blockquote. \n> \n> > Nested blockquote inside. \n\n---\n\n## 7. Horizontal Rules\n\n--- \n*** \n___ \n\n---\n\n## 8. Tables\n\n| Feature | Supported | Notes |\n|----------------|-----------|--------------------------------|\n| **Bold** | โœ… | Works inside tables too |\n| *Italics* | โœ… | Styling works fine |\n| Links | โœ… | [Example](https://openai.com) |\n| Images | โœ… | ![Img](/img/TokenExample.png) |\n| Task List | โŒ | Not supported in table cells |\n\n---\n\n## 9. Inline Formatting\n\nSuperscript: Xยฒ \nSubscript: Hโ‚‚O \nEmoji: ๐ŸŽ‰ ๐Ÿš€ ๐ŸŒ \nHTML inside markdown: highlighted text \n\n---\n\n## 10. Footnotes\n\nHereโ€™s a statement with a footnote.[^1] \n\n[^1]: This is the footnote explanation. \n\n---\n\n## 11. Definition Lists\n\nTerm 1 \n: Definition of term 1 \n\nTerm 2 \n: Definition of term 2 with *emphasis* \n\n---\n\n## 12. Escaping Characters\n\n\\*Not italic\\* but literal asterisks \nUse a backslash for: \\# \\* \\[ \\] \\( \\) \n\n---\n\n## 13. Code Blocks\n\n```csharp\n\nConsole.WriteLine(\"test\");\n\n```\n\n---\n\nThatโ€™s the **full tour** of markdown features." + } + ] } ] """); } + // Avoid zero seconds span. await Task.Delay(100); activity?.Stop(); @@ -410,6 +433,24 @@ async IAsyncEnumerable WriteOutput() return "Created GenAI trace"; }); +app.MapGet("/genai-trace-display-error", async () => +{ + var source = new ActivitySource("Services.Api", "1.0.0"); + + var activity = source.StartActivity("chat gpt", ActivityKind.Client); + if (activity != null) + { + activity.SetTag("gen_ai.system", "gpt"); + activity.SetTag("gen_ai.input.messages", "invalid"); + } + + // Avoid zero seconds span. + await Task.Delay(100); + + activity?.Stop(); + + return "Created GenAI trace"; +}); app.Run(); public record WeatherForecast(DateOnly Date, int TemperatureC, string Summary); diff --git a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs index b1a8c5dc4ac..498d9b22156 100644 --- a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs +++ b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs @@ -16,14 +16,16 @@ namespace Aspire.Dashboard.Model.GenAI; [DebuggerDisplay("Span = {Span.SpanId}, Title = {Title}, Items = {Items.Count}")] public sealed class GenAIVisualizerDialogViewModel { + // The exact name doesn't matter. A value is required when resolving color for peer. + private const string UnknownPeerName = "unknown-peer"; + public required OtlpSpan Span { get; init; } public required string Title { get; init; } public required SpanDetailsViewModel SpanDetailsViewModel { get; init; } public required long? SelectedLogEntryId { get; init; } public required Func> GetContextGenAISpans { get; init; } - - public string? PeerName { get; set; } - public string? SourceName { get; set; } + public required string PeerName { get; init; } + public required string SourceName { get; init; } public FluentTreeItem? SelectedTreeItem { get; set; } public List Items { get; } = new List(); @@ -42,27 +44,21 @@ public static GenAIVisualizerDialogViewModel Create( TelemetryRepository telemetryRepository, Func> getContextGenAISpans) { + var resources = telemetryRepository.GetResources(); + var viewModel = new GenAIVisualizerDialogViewModel { Span = spanDetailsViewModel.Span, Title = SpanWaterfallViewModel.GetTitle(spanDetailsViewModel.Span, spanDetailsViewModel.Resources), SpanDetailsViewModel = spanDetailsViewModel, SelectedLogEntryId = selectedLogEntryId, - GetContextGenAISpans = getContextGenAISpans + GetContextGenAISpans = getContextGenAISpans, + SourceName = OtlpResource.GetResourceName(spanDetailsViewModel.Span.Source, resources), + PeerName = telemetryRepository.GetPeerResource(spanDetailsViewModel.Span) is { } peerResource + ? OtlpResource.GetResourceName(peerResource, resources) + : OtlpHelpers.GetPeerAddress(spanDetailsViewModel.Span.Attributes) ?? UnknownPeerName }; - var resources = telemetryRepository.GetResources(); - viewModel.SourceName = OtlpResource.GetResourceName(viewModel.Span.Source, resources); - - if (telemetryRepository.GetPeerResource(viewModel.Span) is { } peerResource) - { - viewModel.PeerName = OtlpResource.GetResourceName(peerResource, resources); - } - else - { - viewModel.PeerName = OtlpHelpers.GetPeerAddress(viewModel.Span.Attributes)!; - } - viewModel.ModelName = viewModel.Span.Attributes.GetValue(GenAIHelpers.GenAIResponseModel); viewModel.InputTokens = viewModel.Span.Attributes.GetValueAsInteger(GenAIHelpers.GenAIUsageInputTokens); viewModel.OutputTokens = viewModel.Span.Attributes.GetValueAsInteger(GenAIHelpers.GenAIUsageOutputTokens); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs new file mode 100644 index 00000000000..c8273b69d69 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs @@ -0,0 +1,146 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Dashboard.Components.Dialogs; +using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.GenAI; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; +using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +public class GenAIVisualizerDialogTests : DashboardTestContext +{ + private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task Render_NoGenAIAttributes_Success() + { + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + var resource = new OtlpResource("app", "instance", uninstrumentedPeer: false, context); + + var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, DateTime.MinValue); + var scope = CreateOtlpScope(context); + + var cut = SetUpDialog(out var dialogService); + await GenAIVisualizerDialog.OpenDialogAsync( + viewportInformation: new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false), + dialogService: dialogService, + dialogsLoc: Services.GetRequiredService>(), + span: CreateOtlpSpan(resource, trace, scope, spanId: "abc", parentSpanId: null, startDate: s_testTime), + selectedLogEntryId: null, + telemetryRepository: Services.GetRequiredService(), + resources: [], + getContextGenAISpans: () => [] + ); + + var instance = cut.FindComponent().Instance; + + Assert.Empty(instance.Content.Items); + Assert.Equal("app", instance.Content.SourceName); + Assert.Equal("unknown-peer", instance.Content.PeerName); + } + + [Fact] + public async Task Render_HasGenAIMessages_Success() + { + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + var resource = new OtlpResource("app", "instance", uninstrumentedPeer: false, context); + + var systemInstruction = JsonSerializer.Serialize(new List + { + new TextPart { Content = "System!" } + }, GenAIMessagesContext.Default.ListMessagePart); + + var inputMessages = JsonSerializer.Serialize(new List + { + new ChatMessage + { + Role = "user", + Parts = [new TextPart { Content = "User!" }] + }, + new ChatMessage + { + Role = "assistant", + Parts = [new ToolCallRequestPart { Name = "generate_names", Arguments = JsonNode.Parse(@"{""count"":2}") }] + }, + new ChatMessage + { + Role = "user", + Parts = [new ToolCallResponsePart { Response = JsonNode.Parse(@"[""Jack"",""Jane""]") }] + } + }, GenAIMessagesContext.Default.ListChatMessage); + + var outputMessages = JsonSerializer.Serialize(new List + { + new ChatMessage + { + Role = "assistant", + Parts = [new TextPart { Content = "Output!" }] + } + }, GenAIMessagesContext.Default.ListChatMessage); + + var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, DateTime.MinValue); + var scope = CreateOtlpScope(context); + var span = CreateOtlpSpan(resource, trace, scope, spanId: "abc", parentSpanId: null, startDate: s_testTime, attributes: [ + KeyValuePair.Create(GenAIHelpers.GenAISystemInstructions, systemInstruction), + KeyValuePair.Create(GenAIHelpers.GenAIInputMessages, inputMessages), + KeyValuePair.Create(GenAIHelpers.GenAIOutputInstructions, outputMessages) + ]); + + var cut = SetUpDialog(out var dialogService); + await GenAIVisualizerDialog.OpenDialogAsync( + viewportInformation: new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false), + dialogService: dialogService, + dialogsLoc: Services.GetRequiredService>(), + span: span, + selectedLogEntryId: null, + telemetryRepository: Services.GetRequiredService(), + resources: [], + getContextGenAISpans: () => [] + ); + + var instance = cut.FindComponent().Instance; + + Assert.Equal(5, instance.Content.Items.Count); + } + + private IRenderedFragment SetUpDialog(out IDialogService dialogService) + { + var version = typeof(FluentMain).Assembly.GetName().Version!; + + Services.AddFluentUIComponents(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(new ThemeManager(new TestThemeResolver())); + + Services.AddLocalization(); + Services.AddSingleton(); + + var cut = Render(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + + // Setting a provider ID on menu service is required to simulate on the page. + // This makes FluentMenu render without error. + var menuService = Services.GetRequiredService(); + menuService.ProviderId = "Test"; + + dialogService = Services.GetRequiredService(); + return cut; + } +}