Skip to content

Commit 3f9bb4d

Browse files
CopilotJamesNK
andauthored
Parse GenAI tool definitions from span attributes and display in UI with accordion layout (#12863)
Co-authored-by: JamesNK <[email protected]> Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: James Newton-King <[email protected]>
1 parent d27bba2 commit 3f9bb4d

29 files changed

+1200
-19
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@
193193
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="$(MicrosoftAspNetCoreOutputCachingStackExchangeRedisLTSVersion)" />
194194
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="$(MicrosoftAspNetCoreTestHostLTSVersion)" />
195195
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="$(MicrosoftExtensionsCachingMemoryLTSVersion)" />
196+
<PackageVersion Include="Microsoft.OpenApi" Version="1.6.25" />
196197
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="$(MicrosoftExtensionsCachingStackExchangeRedisLTSVersion)" />
197198
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="$(MicrosoftExtensionsDiagnosticsHealthChecksEntityFrameworkCoreLTSVersion)" />
198199
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="$(MicrosoftExtensionsDiagnosticsHealthChecksLTSVersion)" />

playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/Components/Pages/Home.razor

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@page "/"
22
@using Microsoft.Extensions.AI
33
@using System.Diagnostics
4+
@using System.ComponentModel
45
@inject IChatClient chatClient
56
@inject ILogger<Home> logger
67

@@ -25,13 +26,25 @@
2526
"Richard", "Susan", "Joseph", "Jessica", "Thomas", "Sarah",
2627
"Charles", "Karen"
2728
};
29+
private static readonly string[] s_plotTwists = [
30+
"The mentor is actually the villain in disguise.",
31+
"The protagonist’s memories were fabricated to hide the truth.",
32+
"The supposed enemy has been protecting the hero all along.",
33+
"A trusted ally betrays the group for personal gain.",
34+
"The artifact everyone seeks never existed — it was a myth to test them.",
35+
"The villain is revealed to be a future version of the protagonist.",
36+
"The world the characters live in is a simulation.",
37+
"The prophecy was mistranslated — the hero is not the savior but the destroyer.",
38+
"The character believed dead returns with no memory of their past.",
39+
"The conflict was orchestrated by a third unseen force manipulating both sides."
40+
];
2841

2942
private List<ChatMessage> chatMessages = new List<ChatMessage>
3043
{
3144
new(ChatRole.System, "You are a story generator. Format stories in Markdown. Use bold and italics for emphasis. Each sentence must include a Markdown formatted link to Wikipedia about a topic in the sentence.")
3245
};
3346

34-
public Task<List<string>> GenerateNamesAsync(int number)
47+
public Task<List<string>> GenerateNamesAsync([Description("The number of fictional names to generate.")] int number)
3548
{
3649
// Note that this could randomly select the same name multiple times.
3750
var names = Enumerable.Range(1, number)
@@ -41,11 +54,19 @@
4154
return Task.FromResult(names);
4255
}
4356

57+
public Task<string> GeneratePlotTwist()
58+
{
59+
return Task.FromResult(s_plotTwists[Random.Shared.Next(0, s_plotTwists.Length)]);
60+
}
61+
4462
private async Task GenerateNextParagraph()
4563
{
4664
var chatOptions = new ChatOptions
4765
{
48-
Tools = [AIFunctionFactory.Create(GenerateNamesAsync, name: "generate_names", description: "Generates a list of fictional names for the story.")]
66+
Tools = [
67+
AIFunctionFactory.Create(GenerateNamesAsync, name: "generate_names", description: "Generates a list of fictional names for the story."),
68+
AIFunctionFactory.Create(GeneratePlotTwist, name: "generate_plot_twist", description: "Generates a plot twist for the story.")
69+
]
4970
};
5071

5172
if (chatMessages.Count > 1)

src/Aspire.Dashboard/Aspire.Dashboard.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
5858
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" />
5959
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" />
60+
<PackageReference Include="Microsoft.OpenApi" />
6061
<PackageReference Include="ModelContextProtocol" />
6162
<PackageReference Include="ModelContextProtocol.AspNetCore" />
6263
</ItemGroup>

src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
@using Aspire.Dashboard.Resources
66
@using Aspire.Dashboard.Utils
77
@using System.Globalization
8+
@using Microsoft.OpenApi.Any
89

910
@implements IDialogContentComponent<GenAIVisualizerDialogViewModel>
1011

@@ -135,6 +136,39 @@
135136
{
136137
<TextVisualizer ViewModel="@itemPart.TextVisualizerViewModel" HideLineNumbers="true" Virtualize="false" />
137138
}
139+
140+
if (itemPart.MessagePart is ToolCallRequestPart toolCallPart && !string.IsNullOrEmpty(toolCallPart.Name))
141+
{
142+
if (Content.ToolDefinitions.FirstOrDefault(d => d.ToolDefinition.Name == toolCallPart.Name) is { } toolVM)
143+
{
144+
<div class="tool-button-container">
145+
<FluentButton OnClick="@(() => ViewToolDefinition(toolVM))">
146+
<FluentIcon Value="@s_wrenchIcon" Style="vertical-align: sub;" slot="start" />
147+
Tool definition
148+
</FluentButton>
149+
</div>
150+
}
151+
}
152+
else if (itemPart.MessagePart is ToolCallResponsePart toolCallResponsePart && !string.IsNullOrEmpty(toolCallResponsePart.Id))
153+
{
154+
if (TryGetToolCall(toolCallResponsePart.Id, out var itemVM, out var toolCallRequestPart))
155+
{
156+
<div class="tool-button-container">
157+
<FluentButton OnClick="@(() => OnViewItem(itemVM))">
158+
<FluentIcon Value="@s_toolIcon" Style="vertical-align: sub;" slot="start" />
159+
Tool call
160+
</FluentButton>
161+
162+
@if (Content.ToolDefinitions.FirstOrDefault(d => d.ToolDefinition.Name == toolCallRequestPart.Name) is { } toolVM)
163+
{
164+
<FluentButton OnClick="@(() => ViewToolDefinition(toolVM))">
165+
<FluentIcon Value="@s_wrenchIcon" Style="vertical-align: sub;" slot="start" />
166+
Tool definition
167+
</FluentButton>
168+
}
169+
</div>
170+
}
171+
}
138172
}
139173
}
140174
</div>
@@ -201,6 +235,13 @@
201235
Id="@($"tab-overview-{OverviewViewKind.Details}")"
202236
Label="@Loc[nameof(Dialogs.GenAIDetailsTabText)]">
203237
</FluentTab>
238+
<FluentTab LabelClass="tab-label"
239+
Id="@($"tab-overview-{OverviewViewKind.Tools}")">
240+
<Header>
241+
<span class="tab-text">@Loc[nameof(Dialogs.GenAIToolsTabText)]</span>
242+
<FluentBadge Appearance="Appearance.Neutral" Circular="true">@Content.ToolDefinitions.Count</FluentBadge>
243+
</Header>
244+
</FluentTab>
204245
</FluentTabs>
205246
</div>
206247
@if (OverviewActiveView == OverviewViewKind.InputOutput)
@@ -238,6 +279,81 @@
238279
<SpanDetails ViewModel="@Content.SpanDetailsViewModel" HideToolbar="true" />
239280
</div>
240281
}
282+
@if (OverviewActiveView == OverviewViewKind.Tools)
283+
{
284+
<div class="tab-container">
285+
@if (Content.ToolDefinitions.Count > 0)
286+
{
287+
<FluentAccordion Class="tools-list">
288+
@foreach (var toolVM in Content.ToolDefinitions.Where(t => t.ToolDefinition.Type == "function"))
289+
{
290+
<FluentAccordionItem @bind-Expanded="toolVM.Expanded">
291+
<HeadingTemplate>
292+
<div class="tool-heading">
293+
<FluentBadge Appearance="Appearance.Accent" Style="min-width: 80px; text-align: center;">
294+
@toolVM.ToolDefinition.Type
295+
</FluentBadge>
296+
<strong>@toolVM.ToolDefinition.Name</strong>
297+
@if (!string.IsNullOrEmpty(toolVM.ToolDefinition.Description))
298+
{
299+
<span>@FormatHelpers.TruncateText(toolVM.ToolDefinition.Description, maxLength: 100)</span>
300+
}
301+
</div>
302+
</HeadingTemplate>
303+
<ChildContent>
304+
@if (toolVM.ToolDefinition.Parameters?.Properties.Count > 0)
305+
{
306+
<table class="tool-parameters-table">
307+
<thead>
308+
<tr>
309+
<th class="tool-cell tool-cell-nowrap">@Loc[nameof(Dialogs.GenAIToolParameterName)]</th>
310+
<th class="tool-cell tool-cell-nowrap">@Loc[nameof(Dialogs.GenAIToolParameterType)]</th>
311+
<th class="tool-cell">@Loc[nameof(Dialogs.GenAIToolParameterDescription)]</th>
312+
</tr>
313+
</thead>
314+
<tbody>
315+
@foreach (var prop in toolVM.ToolDefinition.Parameters.Properties)
316+
{
317+
<tr>
318+
<td class="tool-cell tool-cell-nowrap">
319+
<strong>@prop.Key</strong>
320+
@if (toolVM.ToolDefinition.Parameters.Required?.Contains(prop.Key) == true)
321+
{
322+
<span style="color: var(--error);"> *</span>
323+
}
324+
</td>
325+
<td class="tool-cell tool-cell-nowrap">
326+
<code>@prop.Value.Type</code>
327+
</td>
328+
<td class="tool-cell">
329+
@prop.Value.Description
330+
</td>
331+
</tr>
332+
}
333+
</tbody>
334+
</table>
335+
@if (toolVM.ToolDefinition.Parameters.Required?.Count > 0)
336+
{
337+
<div class="tool-footer">
338+
<span style="color: var(--error);">*</span> @Loc[nameof(Dialogs.GenAIToolRequiredParameter)]
339+
</div>
340+
}
341+
}
342+
else
343+
{
344+
<div class="tool-footer">@Loc[nameof(Dialogs.GenAIToolNoParameters)]</div>
345+
}
346+
</ChildContent>
347+
</FluentAccordionItem>
348+
}
349+
</FluentAccordion>
350+
}
351+
else
352+
{
353+
<p>@Loc[nameof(Dialogs.GenAINoTools)]</p>
354+
}
355+
</div>
356+
}
241357
}
242358
</div>
243359
</Panel2>

src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@
1313
using Microsoft.AspNetCore.Components;
1414
using Microsoft.Extensions.Localization;
1515
using Microsoft.FluentUI.AspNetCore.Components;
16+
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
1617

1718
namespace Aspire.Dashboard.Components.Dialogs;
1819

1920
public partial class GenAIVisualizerDialog : ComponentBase, IDisposable
2021
{
22+
private static readonly Icon s_wrenchIcon = new Icons.Regular.Size16.Wrench();
23+
private static readonly Icon s_toolIcon = new Icons.Regular.Size16.Code();
24+
2125
private readonly string _copyButtonId = $"copy-{Guid.NewGuid():N}";
2226

2327
private MarkdownProcessor _markdownProcess = default!;
@@ -110,6 +114,33 @@ private void OnViewItem(GenAIItemViewModel viewModel)
110114
SelectedItem = viewModel;
111115
}
112116

117+
private void ViewToolDefinition(ToolDefinitionViewModel toolDefinition)
118+
{
119+
SelectedItem = null;
120+
OverviewActiveView = OverviewViewKind.Tools;
121+
toolDefinition.Expanded = true;
122+
}
123+
124+
private bool TryGetToolCall(string id, [NotNullWhen(true)] out GenAIItemViewModel? itemVM, [NotNullWhen(true)] out ToolCallRequestPart? toolCallRequestPart)
125+
{
126+
foreach (var messages in Content.InputMessages)
127+
{
128+
foreach (var part in messages.ItemParts)
129+
{
130+
if (part.MessagePart is ToolCallRequestPart { } p && p.Id == id)
131+
{
132+
itemVM = messages;
133+
toolCallRequestPart = p;
134+
return true;
135+
}
136+
}
137+
}
138+
139+
itemVM = null;
140+
toolCallRequestPart = null;
141+
return false;
142+
}
143+
113144
private Task HandleSelectedTreeItemChangedAsync()
114145
{
115146
var selectedIndex = Content.SelectedTreeItem?.Data as int?;

src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.css

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
grid-template-rows: auto 1fr auto;
1010
}
1111

12-
::deep .span-messages-container fluent-button::part(control):not(:hover) {
12+
::deep .span-messages-container fluent-button.message-copy-button::part(control):not(:hover) {
1313
background: var(--main-container-background-color);
1414
}
1515

@@ -102,6 +102,12 @@
102102
--icon-color: var(--tool-calls-color);
103103
}
104104

105+
::deep .category-tool-response {
106+
background-color: var(--tool-response-background-color);
107+
color: var(--tool-response-color);
108+
--icon-color: var(--tool-response-color);
109+
}
110+
105111
::deep .category-output {
106112
background-color: var(--output-background-color);
107113
color: var(--output-color);
@@ -168,6 +174,10 @@
168174
display: block;
169175
}
170176

177+
::deep .property-grid-container {
178+
--property-grid-background-color: var(--property-grid-genai-dialog-background-color);
179+
}
180+
171181
::deep .message-container .log-content {
172182
/* don't break on every word */
173183
word-break: normal;
@@ -217,3 +227,68 @@
217227
::deep .markdown-container {
218228
width: 100%;
219229
}
230+
231+
.genai-visualizer-container ::deep .tab-text {
232+
padding-right: 4px;
233+
}
234+
235+
::deep .tools-list {
236+
width: 100%;
237+
}
238+
239+
::deep .tab-container .tools-list fluent-accordion-item {
240+
--fill-color: var(--property-grid-genai-dialog-background-color);
241+
}
242+
243+
::deep .tab-container .tools-list fluent-accordion-item,
244+
::deep .tab-container .tools-list fluent-accordion-item::part(region) {
245+
background-color: var(--property-grid-genai-dialog-background-color);
246+
}
247+
248+
/* This is a hack because we can't do a selector that selects inside of a ::part,
249+
which is what we'd need to do to access `::part(heading):not(:hover) .icon`.
250+
So instead we just change the color token the non-hovered state uses.
251+
*/
252+
::deep .tab-container .tools-list fluent-accordion-item::part(icon) {
253+
--neutral-fill-stealth-rest-on-neutral-fill-layer-rest: var(--property-grid-genai-dialog-background-color);
254+
}
255+
256+
::deep .tab-container .tool-heading {
257+
display: flex;
258+
align-items: center;
259+
gap: 12px;
260+
padding-left: 4px;
261+
}
262+
263+
::deep .tab-container .tool-footer {
264+
padding-left: 4px;
265+
}
266+
267+
::deep .tab-container .tool-parameters-table {
268+
width: 100%;
269+
border-collapse: collapse;
270+
}
271+
272+
::deep .tab-container .tool-button-container {
273+
display: flex;
274+
gap: 8px;
275+
margin-top: 4px;
276+
}
277+
278+
::deep .tab-container .tool-cell {
279+
text-align: left;
280+
padding: 8px;
281+
border-bottom: 1px solid var(--neutral-stroke-divider-rest);
282+
}
283+
284+
::deep .tab-container strong {
285+
font-weight: 600;
286+
}
287+
288+
::deep .tab-container th.tool-cell {
289+
font-weight: 600;
290+
}
291+
292+
::deep .tab-container .tool-cell-nowrap {
293+
text-wrap: nowrap;
294+
}

src/Aspire.Dashboard/Model/GenAI/GenAIHelpers.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public static class GenAIHelpers
1616
public const string GenAIResponseModel = "gen_ai.response.model";
1717
public const string GenAIUsageInputTokens = "gen_ai.usage.input_tokens";
1818
public const string GenAIUsageOutputTokens = "gen_ai.usage.output_tokens";
19+
public const string GenAIToolDefinitions = "gen_ai.tool.definitions";
1920

2021
// LangSmith OpenTelemetry genai standard attributes (flattened format)
2122
public const string GenAIPromptPrefix = "gen_ai.prompt.";

0 commit comments

Comments
 (0)