Skip to content

Commit 843eb87

Browse files
mattleibowCopilot
andcommitted
Phase 1: Add AppChat sessions powered by on-device SLM
Implement local AI-powered chat sessions using Microsoft.Extensions.AI and Microsoft Agent Framework. AppChat sessions appear alongside Copilot sessions in the sidebar with distinct visual treatment. New files: - Models/SessionKind.cs: Copilot | AppChat enum - Services/AI/DirectLocalChatService.cs: Wraps ChatClientAgent with AgentSession per chat, streaming via RunStreamingAsync - Services/AI/AppChatTools.cs: Read-only tools (GetSessionList, GetOrganizationState, GetQueuedMessages, GetRecentErrors) - Services/AI/NonFunctionInvokingChatClient.cs: Workaround for dotnet/extensions#7204 (double tool invocation) Key changes: - SessionState.Session is now nullable (CopilotSession?) - AgentSessionInfo gains Kind property (defaults to Copilot) - CopilotService routes SendPromptAsync by SessionKind - MauiProgram registers keyed 'local' IChatClient pipeline - ExpandedSessionView hides Copilot-only controls for AppChat (Fiesta, ReflectionCycle, Plan/Autopilot, model selector) - Sidebar gets '✨ App Chat' button in both desktop and flyout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 150c873 commit 843eb87

12 files changed

Lines changed: 678 additions & 23 deletions

PolyPilot.Tests/PolyPilot.Tests.csproj

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
<ItemGroup>
1111
<PackageReference Include="coverlet.collector" Version="8.0.0" />
1212
<PackageReference Include="GitHub.Copilot.SDK" Version="0.1.24-preview.0" />
13-
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
14-
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
13+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
14+
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
15+
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
16+
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.260212.1" />
1517
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
1618
<PackageReference Include="xunit" Version="2.9.3" />
1719
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
@@ -51,6 +53,10 @@
5153
<Compile Include="../PolyPilot/Services/DevTunnelService.cs" Link="Shared/DevTunnelService.cs" />
5254
<Compile Include="../PolyPilot/Services/FiestaService.cs" Link="Shared/FiestaService.cs" />
5355
<Compile Include="../PolyPilot/Models/ReflectionCycle.cs" Link="Shared/ReflectionCycle.cs" />
56+
<Compile Include="../PolyPilot/Models/SessionKind.cs" Link="Shared/SessionKind.cs" />
57+
<Compile Include="../PolyPilot/Services/AI/DirectLocalChatService.cs" Link="Shared/DirectLocalChatService.cs" />
58+
<Compile Include="../PolyPilot/Services/AI/AppChatTools.cs" Link="Shared/AppChatTools.cs" />
59+
<Compile Include="../PolyPilot/Services/AI/NonFunctionInvokingChatClient.cs" Link="Shared/NonFunctionInvokingChatClient.cs" />
5460
</ItemGroup>
5561

5662
</Project>

PolyPilot/Components/ExpandedSessionView.razor

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
@{
88
var isCompleted = IsCompleted;
99
var cardClass = Session.IsProcessing ? "processing" : isCompleted ? "completed" : "idle";
10+
var isCopilot = Session.Kind == SessionKind.Copilot;
1011
}
1112
<div class="expanded-card @cardClass" data-session="@Session.Name">
1213
<div class="chat-header">
@@ -16,7 +17,7 @@
1617
{
1718
<span class="processing-dot"></span>
1819
}
19-
@if (Session.ReflectionCycle is { IsActive: true, IsPaused: false } rc)
20+
@if (isCopilot && Session.ReflectionCycle is { IsActive: true, IsPaused: false } rc)
2021
{
2122
var progress = rc.MaxIterations > 0 ? (double)rc.CurrentIteration / rc.MaxIterations * 100 : 0;
2223
var truncGoal = rc.Goal.Length > 25 ? rc.Goal[..22] + "" : rc.Goal;
@@ -25,7 +26,7 @@
2526
<span class="reflection-pill-text">🔄 @rc.CurrentIteration/@rc.MaxIterations · @truncGoal</span>
2627
</button>
2728
}
28-
else if (Session.ReflectionCycle is { IsPaused: true } rp)
29+
else if (isCopilot && Session.ReflectionCycle is { IsPaused: true } rp)
2930
{
3031
<span class="reflection-pill paused" data-tooltip="@rp.Goal — Paused">
3132
⏸️ Paused @rp.CurrentIteration/@rp.MaxIterations
@@ -48,20 +49,23 @@
4849
var q = UsageInfo.PremiumQuota;
4950
<div class="info-row"><span class="info-label">Premium</span><span class="info-value">@(q.IsUnlimited ? "Unlimited" : $"{q.EntitlementRequests - q.UsedRequests}/{q.EntitlementRequests} remaining")</span></div>
5051
}
51-
@if (Session.SessionId != null && PlatformHelper.IsDesktop)
52+
@if (isCopilot && Session.SessionId != null && PlatformHelper.IsDesktop)
5253
{
5354
<button class="info-action" @onclick="() => OpenSessionFolder(Session.SessionId)" @onclick:stopPropagation="true">📂 Open session folder</button>
5455
}
5556
</div>
5657
</div>
57-
<button class="icon-badge fiesta-btn @(FiestaActive ? "active" : "")"
58-
data-tooltip="Fiesta workers"
59-
@onclick="ToggleFiestaPicker">🎉</button>
58+
@if (isCopilot)
59+
{
60+
<button class="icon-badge fiesta-btn @(FiestaActive ? "active" : "")"
61+
data-tooltip="Fiesta workers"
62+
@onclick="ToggleFiestaPicker">🎉</button>
63+
}
6064
<button class="icon-badge collapse-card-btn" data-tooltip="Back to grid (Esc / ⌘E)" @onclick="OnCollapse">⊟</button>
6165
</div>
6266
</div>
6367

64-
@if (_showFiestaPicker)
68+
@if (isCopilot && _showFiestaPicker)
6569
{
6670
<div class="fiesta-panel-inline">
6771
<div class="fiesta-row">
@@ -193,11 +197,14 @@
193197
<div class="input-status-bar">
194198
<div class="mode-switcher">
195199
<button class="mode-btn @(InputMode == "chat" ? "active" : "")" @onclick='() => OnSetInputMode.InvokeAsync("chat")'>Chat</button>
196-
<button class="mode-btn @(InputMode == "plan" ? "active" : "")" @onclick='() => OnSetInputMode.InvokeAsync("plan")'>Plan</button>
197-
<button class="mode-btn @(InputMode == "autopilot" ? "active" : "")" @onclick='() => OnSetInputMode.InvokeAsync("autopilot")'>Autopilot</button>
200+
@if (isCopilot)
201+
{
202+
<button class="mode-btn @(InputMode == "plan" ? "active" : "")" @onclick='() => OnSetInputMode.InvokeAsync("plan")'>Plan</button>
203+
<button class="mode-btn @(InputMode == "autopilot" ? "active" : "")" @onclick='() => OnSetInputMode.InvokeAsync("autopilot")'>Autopilot</button>
204+
}
198205

199206
</div>
200-
@if (Session.ReflectionCycle is null || !Session.ReflectionCycle.IsActive)
207+
@if (isCopilot && (Session.ReflectionCycle is null || !Session.ReflectionCycle.IsActive))
201208
{
202209
<button class="mode-btn" style="margin-left:8px;opacity:0.7" title="Start a reflection cycle" @onclick="InsertReflectCommand">Reflect</button>
203210
}
@@ -216,16 +223,23 @@
216223
<span class="skills-trigger" @onclick="ShowAgentsPopup">@availableAgents.Count agents</span>
217224
}
218225
<span class="status-sep">·</span>
219-
<select class="inline-model-select" value="@CurrentModel" @onchange="e => OnSetModel.InvokeAsync(e.Value?.ToString())" disabled="@Session.IsProcessing" title="@(Session.IsProcessing ? "Wait for response to complete" : "Switch model")">
220-
@if (!AvailableModels.Contains(CurrentModel))
221-
{
222-
<option value="@CurrentModel">@PrettifyModel(CurrentModel)</option>
223-
}
224-
@foreach (var m in AvailableModels)
225-
{
226-
<option value="@m">@PrettifyModel(m)</option>
227-
}
228-
</select>
226+
@if (isCopilot)
227+
{
228+
<select class="inline-model-select" value="@CurrentModel" @onchange="e => OnSetModel.InvokeAsync(e.Value?.ToString())" disabled="@Session.IsProcessing" title="@(Session.IsProcessing ? "Wait for response to complete" : "Switch model")">
229+
@if (!AvailableModels.Contains(CurrentModel))
230+
{
231+
<option value="@CurrentModel">@PrettifyModel(CurrentModel)</option>
232+
}
233+
@foreach (var m in AvailableModels)
234+
{
235+
<option value="@m">@PrettifyModel(m)</option>
236+
}
237+
</select>
238+
}
239+
else
240+
{
241+
<span class="status-extra" style="opacity:0.6">✨ Local</span>
242+
}
229243
<div class="status-spacer"></div>
230244
<span class="status-extra">
231245
@if (UsageInfo != null)

PolyPilot/Components/Layout/SessionSidebar.razor

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ else if (IsFlyoutPanel)
3737
CreateError="@createError"
3838
OnCreate="HandleCreateSession"
3939
OnBrowseDirectory="OpenDirectoryPicker" />
40+
<button class="app-chat-btn" @onclick="HandleCreateAppChat" disabled="@isCreating" title="Start an on-device AI chat">✨ App Chat</button>
4041
</div>
4142

4243
<div class="sidebar-divider"></div>
@@ -75,6 +76,7 @@ else
7576
CreateError="@createError"
7677
OnCreate="HandleCreateSession"
7778
OnBrowseDirectory="OpenDirectoryPicker" />
79+
<button class="app-chat-btn" @onclick="HandleCreateAppChat" disabled="@isCreating" title="Start an on-device AI chat">✨ App Chat</button>
7880
</div>
7981

8082
<div class="sidebar-divider"></div>
@@ -645,6 +647,30 @@ else
645647
}
646648
}
647649

650+
private async Task HandleCreateAppChat()
651+
{
652+
if (isCreating) return;
653+
isCreating = true;
654+
createError = null;
655+
try
656+
{
657+
var name = $"App Chat {DateTime.Now:HH:mm}";
658+
var sessionInfo = CopilotService.CreateAppChatSession(name);
659+
CopilotService.SwitchSession(sessionInfo.Name);
660+
CopilotService.SaveUiState(currentPage);
661+
await OnSessionSelected.InvokeAsync();
662+
}
663+
catch (Exception ex)
664+
{
665+
createError = ex.Message;
666+
Console.WriteLine($"Error creating app chat: {ex}");
667+
}
668+
finally
669+
{
670+
isCreating = false;
671+
}
672+
}
673+
648674
private void ConfirmResume(PersistedSessionInfo persisted)
649675
{
650676
resumeError = null;

PolyPilot/Components/Layout/SessionSidebar.razor.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,3 +1263,23 @@
12631263
}
12641264
.bug-report-submit:hover { opacity: 0.85; }
12651265
.bug-report-submit:disabled { opacity: 0.5; cursor: not-allowed; }
1266+
1267+
/* === App Chat Button === */
1268+
.app-chat-btn {
1269+
width: 100%;
1270+
padding: 0.4rem 0.75rem;
1271+
margin-top: 0.35rem;
1272+
border: 1px dashed var(--border-color, rgba(255,255,255,0.12));
1273+
border-radius: 8px;
1274+
background: none;
1275+
color: var(--text-dim, #888);
1276+
cursor: pointer;
1277+
font-size: var(--type-footnote, 0.8rem);
1278+
transition: all 0.15s;
1279+
}
1280+
.app-chat-btn:hover {
1281+
color: var(--text-primary, #fff);
1282+
background: var(--hover-bg, rgba(255,255,255,0.06));
1283+
border-color: var(--accent-color, #58a6ff);
1284+
}
1285+
.app-chat-btn:disabled { opacity: 0.4; cursor: not-allowed; }

PolyPilot/MauiProgram.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
using PolyPilot.Services;
2+
using PolyPilot.Services.AI;
3+
using Microsoft.Extensions.AI;
24
using Microsoft.Extensions.Logging;
35
using ZXing.Net.Maui.Controls;
46
using MauiDevFlow.Agent;
57
using MauiDevFlow.Blazor;
8+
#if MACCATALYST || IOS
9+
using Microsoft.Maui.Essentials.AI;
10+
#endif
611
#if MACCATALYST
712
using Microsoft.Maui.LifecycleEvents;
813
using UIKit;
@@ -104,6 +109,9 @@ public static MauiApp CreateMauiApp()
104109
builder.Services.AddSingleton<RepoManager>();
105110
builder.Services.AddSingleton<INotificationManagerService, NotificationManagerService>();
106111

112+
// Register local AI services (AppChat powered by on-device SLM)
113+
RegisterLocalAI(builder);
114+
107115
#if DEBUG
108116
builder.Services.AddBlazorWebViewDeveloperTools();
109117
builder.Logging.AddDebug();
@@ -126,4 +134,41 @@ private static void LogException(string source, Exception? ex)
126134
}
127135
catch { /* Don't throw in exception handler */ }
128136
}
137+
138+
/// <summary>
139+
/// Register the local AI IChatClient pipeline and DirectLocalChatService.
140+
/// On Apple platforms, uses AppleIntelligenceChatClient + NLEmbeddingGenerator.
141+
/// On other platforms, AppChat is unavailable (no SLM provider yet).
142+
/// </summary>
143+
private static void RegisterLocalAI(MauiAppBuilder builder)
144+
{
145+
#if MACCATALYST || IOS
146+
// Apple Intelligence provides on-device IChatClient + IEmbeddingGenerator
147+
#pragma warning disable MAUIAI0001 // Apple Intelligence is experimental
148+
builder.Services.AddSingleton<AppleIntelligenceChatClient>();
149+
150+
#pragma warning disable MEAI001 // EmbeddingToolReductionStrategy is experimental
151+
builder.Services.AddKeyedSingleton<IChatClient>("local", (sp, _) =>
152+
{
153+
var appleClient = sp.GetRequiredService<AppleIntelligenceChatClient>();
154+
#pragma warning restore MAUIAI0001
155+
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
156+
157+
return appleClient
158+
.AsBuilder()
159+
.UseLogging(loggerFactory)
160+
// Workaround for https://github.com/dotnet/extensions/issues/7204
161+
.Use(cc => new NonFunctionInvokingChatClient(cc, loggerFactory, sp))
162+
.ConfigureOptions(o =>
163+
{
164+
o.MaxOutputTokens = 350;
165+
o.AllowMultipleToolCalls = false;
166+
})
167+
.Build();
168+
});
169+
#pragma warning restore MEAI001
170+
171+
builder.Services.AddSingleton<DirectLocalChatService>();
172+
#endif
173+
}
129174
}

PolyPilot/Models/AgentSessionInfo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ public class AgentSessionInfo
44
{
55
public required string Name { get; set; }
66
public required string Model { get; set; }
7+
public SessionKind Kind { get; set; } = SessionKind.Copilot;
78
public DateTime CreatedAt { get; init; }
89
public int MessageCount { get; set; }
910
public bool IsProcessing { get; set; }

PolyPilot/Models/SessionKind.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace PolyPilot.Models;
4+
5+
[JsonConverter(typeof(JsonStringEnumConverter))]
6+
public enum SessionKind
7+
{
8+
Copilot,
9+
AppChat
10+
}

PolyPilot/PolyPilot.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@
7474
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
7575
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.2" />
7676
<PackageReference Include="Microsoft.Maui.Essentials.AI" Version="10.0.50-ci.main.26117.2" />
77+
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
78+
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.260212.1" />
7779
<PackageReference Include="sqlite-net-pcl" Version="*" />
7880
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="*" />
7981
<PackageReference Include="QRCoder" Version="*" />
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System.Text.Json;
2+
using Microsoft.Extensions.AI;
3+
4+
namespace PolyPilot.Services.AI;
5+
6+
/// <summary>
7+
/// Read-only tools that the local AppChat agent can invoke to query app state.
8+
/// Each method returns a JSON string summary for the SLM.
9+
/// </summary>
10+
public static class AppChatTools
11+
{
12+
/// <summary>
13+
/// Creates the set of AIFunction tools for AppChat sessions.
14+
/// The CopilotService instance is captured by closure.
15+
/// </summary>
16+
public static IList<AIFunction> Create(CopilotService service)
17+
{
18+
return
19+
[
20+
AIFunctionFactory.Create(
21+
() => GetSessionList(service),
22+
nameof(GetSessionList),
23+
"Lists all active Copilot sessions with their name, model, message count, and processing status."),
24+
25+
AIFunctionFactory.Create(
26+
() => GetOrganizationState(service),
27+
nameof(GetOrganizationState),
28+
"Returns the current organization state: session groups, sort mode, and which sessions are pinned."),
29+
30+
AIFunctionFactory.Create(
31+
() => GetQueuedMessages(service),
32+
nameof(GetQueuedMessages),
33+
"Returns queued (pending) messages for each session that has messages waiting to be sent."),
34+
35+
AIFunctionFactory.Create(
36+
() => GetRecentErrors(service),
37+
nameof(GetRecentErrors),
38+
"Returns the most recent error messages from all sessions, if any."),
39+
];
40+
}
41+
42+
private static string GetSessionList(CopilotService service)
43+
{
44+
var sessions = service.GetAllSessions().Select(s => new
45+
{
46+
s.Name,
47+
s.Model,
48+
Kind = s.Kind.ToString(),
49+
s.MessageCount,
50+
s.IsProcessing,
51+
HasQueue = s.MessageQueue.Count > 0,
52+
s.WorkingDirectory,
53+
s.GitBranch
54+
});
55+
return JsonSerializer.Serialize(sessions);
56+
}
57+
58+
private static string GetOrganizationState(CopilotService service)
59+
{
60+
var org = service.Organization;
61+
var result = new
62+
{
63+
SortMode = org.SortMode.ToString(),
64+
Groups = org.Groups.Select(g => new { g.Id, g.Name, g.SortOrder, g.IsCollapsed }),
65+
Sessions = org.Sessions.Select(m => new { m.SessionName, m.GroupId, m.IsPinned })
66+
};
67+
return JsonSerializer.Serialize(result);
68+
}
69+
70+
private static string GetQueuedMessages(CopilotService service)
71+
{
72+
var queued = service.GetAllSessions()
73+
.Where(s => s.MessageQueue.Count > 0)
74+
.Select(s => new
75+
{
76+
s.Name,
77+
QueueCount = s.MessageQueue.Count,
78+
Messages = s.MessageQueue.Take(5).ToList()
79+
});
80+
return JsonSerializer.Serialize(queued);
81+
}
82+
83+
private static string GetRecentErrors(CopilotService service)
84+
{
85+
var errors = service.GetAllSessions()
86+
.SelectMany(s => s.History
87+
.Where(m => m?.MessageType == Models.ChatMessageType.Error)
88+
.TakeLast(3)
89+
.Select(m => new { Session = s.Name, m.Content, m.Timestamp }))
90+
.OrderByDescending(e => e.Timestamp)
91+
.Take(10);
92+
return JsonSerializer.Serialize(errors);
93+
}
94+
}

0 commit comments

Comments
 (0)