Alfred Chat
Client WinUI 3 pour ton agent LLM Python.
Cette doc te fait passer de « j'ai vibe codé ça » à « je maîtrise mon code ».
🌱 Partie 1 — Onboarding
Lis cette partie en premier. 10 minutes. Tu auras une vue claire du projet avant de plonger dans le code.
Qu'est-ce qu'Alfred Chat ?
Alfred Chat est une application Windows native qui te permet de discuter avec un agent IA (Alfred) qui tourne ailleurs en Python. C'est l'équivalent de WhatsApp Desktop, mais au lieu de parler à un humain, tu parles à un LLM.
Ton app sait :
- 📝 envoyer du texte à Alfred et afficher ses réponses
- 🖼️ joindre une image (en base64)
- 🎙️ enregistrer un audio depuis le micro et l'envoyer
- 🔊 jouer un audio reçu en réponse
- 💾 persister l'historique sur disque (en JSON)
- ⚙️ gérer une clé API, une URL serveur, un ChatId
- 📜 afficher des logs internes dans une page dédiée
- ✨ rendre le markdown des réponses (gras, italique, code inline, liens)
À quoi ça ressemble (l'UI)
Trois pages, c'est tout :
ChatPage
La page principale. Liste les messages, gère l'input, les pièces jointes, l'enregistrement audio.
LogsPage
Affiche les logs internes (connexion WS, erreurs, etc.) façon terminal vert sur noir.
SettingsPage
Trois champs : clé API, URL du WebSocket, ChatId (en lecture seule).
L'architecture en 1 minute
Le projet suit le pattern MVVM. Trois couches qui se parlent.
Les 5 concepts à connaître absolument
1. Les pages sont du XAML, pas du HTML
Le XAML, c'est comme du HTML+CSS, mais pour Windows. Chaque page a deux fichiers :
- ChatPage.xaml — la structure visuelle (équivalent du HTML)
- ChatPage.xaml.cs — le "code-behind", le C# qui pilote la page
Les deux sont liés par x:Class="alfred_chat.ChatPage". Le compilateur les fusionne en une seule classe.
2. INotifyPropertyChanged (INPC) : le moteur de la magie
C'est l'interface qui dit à l'UI : « hé, cette propriété a changé, redessine-toi ».
Tu ne l'écris jamais à la main dans ce projet. Tu écris :
[ObservableProperty]
private string _inputText = "";
Et le source generator de CommunityToolkit.Mvvm génère automatiquement la vraie propriété InputText avec toute la plomberie INPC. C'est pour ça que les classes sont partial : la moitié du code est généré par la compilation.
3. ObservableCollection : la liste qui prévient quand elle change
List<T> ne prévient pas l'UI quand tu fais .Add(). ObservableCollection<T>, si. C'est ce qui fait que la ListView des messages affiche le nouveau message automatiquement dès qu'il est ajouté à Messages.
4. x:Bind : le pont entre le XAML et le C#
Dans ce XAML :
<TextBox Text="{x:Bind ViewModel.InputText, Mode=TwoWay}" />
→ Le texte du TextBox est lié à la propriété InputText du ViewModel. TwoWay = les deux sens (le ViewModel met à jour la UI, la UI met à jour le ViewModel).
5. DispatcherQueue : le thread UI est sacré
WinUI a un seul thread qui peut toucher à l'UI. Si tu reçois un message WebSocket en background, tu dois "renvoyer" l'exécution sur ce thread sacré avant de modifier quoi que ce soit :
_dispatcherQueue.TryEnqueue(() =>
{
Messages.Add(message); // ← maintenant on est sur le thread UI
});
Carte du code (où vit quoi)
Que se passe-t-il quand l'utilisateur clique « Envoyer » ?
C'est le flux à comprendre. Tout le reste en découle.
Cheatsheet WinUI 3 (à garder sous le coude)
| Tu veux… | Tu fais |
|---|---|
| Ajouter une propriété observable | [ObservableProperty] private T _name; |
| Ajouter une commande (bouton) | [RelayCommand] private async Task DoStuff() {…} |
| Lier une valeur en XAML | {x:Bind ViewModel.MyProp, Mode=OneWay} |
| Modifier l'UI depuis un thread bg | _dispatcherQueue.TryEnqueue(() => …) |
| Transformer une valeur pour l'UI | Créer un IValueConverter dans Converters/ |
| Afficher/cacher un élément | Visibility="{x:Bind Cond, Converter=...}" |
| Aligner à gauche ou droite | HorizontalAlignment="{x:Bind IsOutgoing, Converter=AlignmentConverter}" |
| Naviguer vers une page | RootFrame.Navigate(typeof(MaPage)) |
| Ouvrir un FileOpenPicker | ⚠️ Toujours appeler InitializeWithWindow.Initialize(picker, hWnd) |
| Afficher un ContentDialog | ⚠️ Toujours passer XamlRoot = this.XamlRoot |
🔬 Partie 2 — Plongée technique
Maintenant qu'on a la vue d'ensemble, on attaque le code fichier par fichier.
App.xaml & App.xaml.cs
Rôle : point d'entrée de l'application (équivalent du Program.cs côté UI). Déclare les ressources globales et expose le ViewModel singleton.
App.xaml — ressources globales
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
<converters:AlignmentConverter x:Key="AlignmentConverter" />
<converters:BackgroundConverter x:Key="BackgroundConverter" />
<!-- … 9 converters au total -->
</ResourceDictionary>
</Application.Resources>
XamlControlsResources charge les styles par défaut de tous les contrôles WinUI. Sans cette ligne, ton app ressemblerait à du Windows 95.
Chaque converter est instancié une seule fois ici, comme ressource avec une x:Key. N'importe quelle page peut y accéder via {StaticResource AlignmentConverter}.
App.xaml.cs — le singleton ViewModel
public partial class App : Application
{
public static ViewModels.MainViewModel ViewModel { get; } = new();
public static Window? MainWindow { get; private set; }
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
MainWindow = new MainWindow();
MainWindow.Activate();
}
}
App.ViewModel est une propriété statique unique. Toutes les pages tapent dedans (App.ViewModel.Messages). Pas d'injection de dépendances, pas de DI container, pas de service locator. C'est simple, ça marche pour ce projet — mais ça empêche les tests unitaires.
WindowNotificationHelper
Classe statique en bas du fichier qui fait du P/Invoke Win32 pour :
- Jouer le son système "Exclamation" via
MessageBeep - Faire clignoter l'icône dans la barre des tâches via
FlashWindowEx
Appelée depuis MainViewModel.ReceiveLoopAsync à chaque message reçu d'Alfred.
MainWindow
Rôle : la fenêtre shell. Contient le menu latéral et un Frame qui héberge les pages. Aucune logique métier.
Points importants du XAML
<Window.SystemBackdrop>
<MicaBackdrop Kind="Base" />
</Window.SystemBackdrop>
Applique l'effet Mica (Fluent Design Windows 11) : fond semi-transparent qui reprend les couleurs du bureau.
<NavigationView PaneDisplayMode="LeftCompact"
IsSettingsVisible="False"
ItemInvoked="MainNav_ItemInvoked">
<NavigationView.MenuItems>
<NavigationViewItem Content="Chat" Icon="Message" Tag="ChatPage" />
<NavigationViewItem Content="Logs" Icon="Document" Tag="LogsPage" />
</NavigationView.MenuItems>
<Frame x:Name="RootFrame" />
</NavigationView>
Le Tag de chaque item est mappé en C# vers un Type de page :
private void MainNav_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args)
{
Type pageType = args.InvokedItemContainer?.Tag switch
{
"ChatPage" => typeof(ChatPage),
"LogsPage" => typeof(LogsPage),
"SettingsPage" => typeof(SettingsPage),
_ => typeof(ChatPage)
};
RootFrame.Navigate(pageType);
}
Le badge "non lu"
private void Messages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems[0] is not ChatMessage msg) return;
if (msg.IsOutgoing || msg.IsSystem) return;
if (RootFrame.CurrentSourcePageType == typeof(ChatPage)) return;
ChatNavBadge.Visibility = Visibility.Visible; // point rouge sur l'onglet Chat
}
Si un message entrant arrive alors que l'utilisateur est sur Logs ou Settings, un InfoBadge apparaît sur l'onglet Chat. Le badge disparaît dès le retour sur ChatPage (handler RootFrame_Navigated).
ChatPage — le gros morceau
287 lignes de XAML, 306 lignes de code-behind. C'est la page la plus complexe.
Structure XAML
<Grid RowDefinitions="Auto, *, Auto, Auto">
Row 0 : CommandBar ← bouton "⋯" (Volume + Clear chat)
Row 1 : Grid ← ListView des messages + bouton scroll-to-bottom
Row 2 : "Alfred is thinking..." (visible quand IsWaitingForReply)
Row 3 : Zone d'input ← previews + bouton + TextBox + bouton Send
</Grid>
Le DataTemplate des messages
Le cœur de l'affichage. Pour chaque ChatMessage dans Messages, ce template est instancié :
<DataTemplate x:DataType="models:ChatMessage">
<Grid>
<!-- Cas 1 : message système (italique, centré, sans bulle) -->
<TextBlock Text="{x:Bind Text}"
Visibility="{x:Bind IsSystem, Converter=...}" />
<!-- Cas 2 : bulle normale (alignée gauche ou droite) -->
<Grid HorizontalAlignment="{x:Bind IsOutgoing, Converter=AlignmentConverter}"
Visibility="{x:Bind IsSystem, Converter=InverseVisibility}">
... image, audio, texte, timestamp ...
</Grid>
</Grid>
</DataTemplate>
x:DataType="models:ChatMessage" est essentiel : ça permet à x:Bind de connaître le type à la compilation et de vérifier que les propriétés existent.
md:MarkdownText.Source). Visibilité conditionnelle. C'est moche mais ça marche.
Le scroll automatique
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<ItemsStackPanel ItemsUpdatingScrollMode="KeepLastItemInView" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
Cette propriété fait que la ListView garde automatiquement le dernier message visible si on est déjà scrollé en bas. Côté code-behind, Messages_CollectionChanged affine :
- Message sortant → toujours scroller en bas
- Message entrant + on est en bas → scroller en bas
- Message entrant + on est en haut → afficher le bouton ⬇ flottant + point rouge non lu
GetScrollViewer() traverse l'arbre visuel (VisualTreeHelper) pour retrouver le ScrollViewer interne de la ListView — il n'est pas exposé directement par le framework.
Gestion audio (le casse-tête)
_audioPlayers est un HashSet<MediaPlayerElement> qui garde une référence sur tous les players audio actifs. Deux usages :
- Volume global : quand
ViewModel.Volumechange, on applique sur tous les players - Durée du média : on hook
MediaPlayer.MediaOpenedpour récupérer la durée et la stocker dansChatMessage.Duration
ScheduleHideUnwantedButtons() est une technique sale mais nécessaire : les boutons "Compact Overlay", "Cast", "Full Window" du MediaTransportControls ne sont pas exposés comme propriétés dans WinUI 3. Il faut traverser l'arbre visuel et les cacher par leur Name, avec retry car l'arbre n'est pas immédiatement disponible après Loaded.
private void ScheduleHideUnwantedButtons(MediaPlayerElement element, int retries)
{
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
{
bool found = HideTransportButtonsByName(element,
"CompactOverlayButton", "CastButton",
"PlayOnButton", "FullWindowButton");
if (!found && retries > 0)
ScheduleHideUnwantedButtons(element, retries - 1); // retry
});
}
LogsPage & SettingsPage
LogsPage : ultra simple
public sealed partial class LogsPage : Page
{
public MainViewModel ViewModel => App.ViewModel;
public LogsPage() { InitializeComponent(); }
}
Affiche ViewModel.AppLogs (ObservableCollection<string>) dans une ListView stylée comme un terminal (fond noir, texte vert #4AF626, police Consolas).
SettingsPage : pas de MVVM
private void LoadCurrentSettings()
{
ChatIdBox.Text = App.ViewModel.ChatId;
ApiKeyBox.Password = App.ViewModel.ApiKey;
TargetUrlBox.Text = App.ViewModel.TargetUrl;
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
App.ViewModel.ApiKey = ApiKeyBox.Password;
App.ViewModel.TargetUrl = TargetUrlBox.Text;
App.ViewModel.SaveSettings();
Frame.Navigate(typeof(ChatPage));
}
Pas de binding bidirectionnel. Les champs sont lus à l'arrivée sur la page et écrits au clic sur Save. C'est du code-behind classique, pas du MVVM pur. Acceptable pour 3 champs, mais ne passe pas à l'échelle.
MainViewModel — le cœur
440 lignes. Gère tout : WebSocket, audio, fichiers, logs, settings, état UI. C'est le principal point de dette architecturale.
Anatomie de la classe
Les ObservableProperty
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPendingImage))]
private string? _pendingImageBase64 = null;
public bool HasPendingImage => !string.IsNullOrEmpty(PendingImageBase64);
[NotifyPropertyChangedFor] dit au source generator : "quand PendingImageBase64 change, notifie aussi HasPendingImage". Sans ça, le binding sur HasPendingImage dans le XAML ne se rafraîchirait jamais.
La boucle WebSocket
private async Task ConnectWebSocketAsync()
{
_cts = new CancellationTokenSource();
_webSocket = new ClientWebSocket();
string wsUrl = $"{TargetUrl.TrimEnd('/')}/{ChatId}";
_webSocket.Options.SetRequestHeader("X-Alfred-Key", ApiKey);
await _webSocket.ConnectAsync(new Uri(wsUrl), _cts.Token);
_ = ReceiveLoopAsync(_cts.Token); // fire-and-forget
}
_ = ReceiveLoopAsync(...) : c'est du fire-and-forget. La boucle de réception tourne en arrière-plan indéfiniment. Le _ discard évite le warning "Task non attendu".
private async Task ReceiveLoopAsync(CancellationToken token)
{
var buffer = new byte[1024 * 64]; // 64 Ko
while (_webSocket.State == WebSocketState.Open && !token.IsCancellationRequested)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do {
result = await _webSocket.ReceiveAsync(...);
ms.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage); // peut arriver en N frames
var responseData = JsonSerializer.Deserialize<WebSocketResponse>(json, options);
_dispatcherQueue.TryEnqueue(() =>
{
IsWaitingForReply = false;
Messages.Add(message);
WindowNotificationHelper.FlashTaskbarAndBeep();
});
}
}
EndOfMessage = true. C'est une bonne pratique souvent oubliée.
Le record WebSocketResponse
private record WebSocketResponse(
[property: JsonPropertyName("chat_id")] string ChatId,
[property: JsonPropertyName("text")] string Text,
[property: JsonPropertyName("image_b64")] string ImageBase64,
[property: JsonPropertyName("audio_b64")] string AudioBase64,
[property: JsonPropertyName("link_confirmed")] string? LinkConfirmed,
[property: JsonPropertyName("stats")] object? Stats);
Mappe le JSON snake_case de Python vers le PascalCase de C#. C'est un record = type immuable, parfait pour de la sérialisation.
ChatMessage — le modèle
Un seul message. Circule entre le ViewModel et le DataTemplate de la View.
public partial class ChatMessage : ObservableObject
{
public string Text { get; set; } = "";
public string Sender { get; set; } = "Unknown";
public DateTime Timestamp { get; set; } = DateTime.Now;
public bool IsOutgoing { get; set; }
public string? ImageBase64 { get; set; }
public string? AudioBase64 { get; set; }
[ObservableProperty]
[property: JsonIgnore]
[NotifyPropertyChangedFor(nameof(TimestampWithDuration))]
private string? _duration;
// Propriétés calculées utilisées dans le XAML
public bool HasImage => !string.IsNullOrEmpty(ImageBase64);
public bool HasAudio => !string.IsNullOrEmpty(AudioBase64);
public bool HasIncomingText => HasText && !IsOutgoing;
public bool HasOutgoingText => HasText && IsOutgoing;
public bool IsSystem => Sender == "System";
public string FormattedTime => Timestamp.ToString("HH:mm");
}
Les propriétés calculées (HasImage, IsSystem...) sont directement utilisées dans le XAML pour piloter la visibilité. Pas besoin de logique dans le code-behind.
[JsonIgnore] sur Duration, TimestampWithDuration, BubblePadding = ces propriétés ne sont pas sérialisées dans l'historique JSON. Elles sont reconstruites au runtime.
StorageService — la persistance
Lit/écrit deux fichiers JSON dans ApplicationData.Current.LocalFolder :
- config.json — clé API, URL, ChatId, volume
- history.json — liste des messages
Sur Windows, ce dossier est spécifique à ton app packagée MSIX, typiquement :
C:\Users\{user}\AppData\Local\Packages\{AppId}\LocalState\
Pattern Load classique :
public async Task<AppConfig> LoadConfigAsync()
{
var file = await ApplicationData.Current.LocalFolder
.TryGetItemAsync(ConfigFileName) as StorageFile;
if (file == null)
{
var config = new AppConfig { ChatId = GenerateChatId() };
await SaveConfigAsync(config);
return config;
}
var json = await FileIO.ReadTextAsync(file);
return JsonSerializer.Deserialize<AppConfig>(json) ?? new AppConfig {...};
}
TryGetItemAsync retourne null si le fichier n'existe pas (n'lève pas d'exception). C'est le pattern WinUI canonique pour vérifier l'existence.
GenerateChatId() utilise new Random() non seedé. Deux instances créées dans la même milliseconde produisent le même ID. Préférer Random.Shared.Next() ou Guid.NewGuid().ToString("N").Substring(0, 10).
Les Converters
Un IValueConverter traduit une valeur du ViewModel en une valeur utilisable par le XAML. x:Bind est typé strict : tu ne peux pas binder un bool directement sur HorizontalAlignment.
| Converter | Entrée | Sortie | Usage |
|---|---|---|---|
AlignmentConverter | bool | HorizontalAlignment | Bulle gauche/droite |
BackgroundConverter | bool | Brush | Fond accent/neutre |
ForegroundConverter | bool | Brush | Texte clair/foncé |
BorderBrushConverter | bool | Brush | Bordure visible/invisible |
VisibilityConverter | bool | Visibility | true → Visible |
InverseVisibilityConverter | bool | Visibility | true → Collapsed |
VolumePercentConverter | double | string | 0.75 → "75%" |
Base64ImageConverter | string | BitmapImage | Image depuis base64 |
Base64MediaSourceConverter | string | MediaSource | Audio depuis base64 |
writer.StoreAsync().GetAwaiter().GetResult();
Async bloquant sur le thread UI. Pour de grandes images/audios, ça freeze l'UI. À refactorer : pré-décoder les médias dans le ViewModel avant de les binder.
MarkdownText — l'Attached Property
C'est une technique WinUI/WPF élégante pour "attacher" une nouvelle propriété à un contrôle existant (ici TextBlock).
public static readonly DependencyProperty SourceProperty =
DependencyProperty.RegisterAttached(
"Source",
typeof(string),
typeof(MarkdownText),
new PropertyMetadata(null, OnSourceChanged));
Usage dans le XAML :
<TextBlock md:MarkdownText.Source="{x:Bind Text}" />
Quand la valeur change, OnSourceChanged est invoqué. Il parse le texte avec une regex compilée et insère des Inline (Run, Hyperlink, Bold, Italic) dans le TextBlock.
private static readonly Regex TokenRegex = new(
@"\[(?<linkText>[^\]]+)\]\((?<linkUrl>[^)]+)\)" + // [text](url)
@"|`(?<code>[^`]+)`" + // `code`
@"|\*\*(?<bold>[^*]+)\*\*" + // **bold**
@"|\*(?<italic>[^*]+)\*", // *italic*
RegexOptions.Compiled);
Markdown supporté : liens, code inline, **gras**, *italique*. C'est tout. Pas de titres, pas de listes, pas de blocs de code.
Threading & DispatcherQueue
Le ViewModel capture le DispatcherQueue dès sa construction (sur le thread UI) :
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
Puis, depuis n'importe quel thread :
_dispatcherQueue.TryEnqueue(() =>
{
Messages.Add(message); // safe, on est sur le thread UI
IsWaitingForReply = false;
});
ObservableProperty ou ObservableCollection depuis un thread non-UI doit passer par _dispatcherQueue.TryEnqueue(). Sinon, crash ou comportement imprévisible.
x:Bind vs Binding
{x:Bind} | {Binding} | |
|---|---|---|
| Évaluation | Compile-time | Runtime |
| Source | Page (chemin direct) | DataContext (hérité) |
| Mode par défaut | OneTime | OneWay |
| Performance | Plus rapide | Plus lent (reflection) |
| Erreurs | À la compilation | À runtime, silencieuses |
Nécessite x:DataType | Oui dans DataTemplate | Non |
Ce projet utilise x:Bind partout. C'est le bon choix moderne.
x:Bind est OneTime : la valeur est lue une seule fois, jamais rafraîchie. Pour les données dynamiques, toujours préciser Mode=OneWay ou Mode=TwoWay.
🔧 Partie 3 — Qualité & Roadmap
Dette technique identifiée
🚨 Critiques
- God Object :
MainViewModelfait 440 lignes et gère 6 responsabilités GetAwaiter().GetResult()bloquant dans les converters Base64- Pas de reconnexion auto si WebSocket coupe
SaveHistoryAsyncfire-and-forget sans gestion d'erreur- Pas de
CancellationTokenà l'arrêt de l'app
⚠️ Modérés
SettingsPagesans binding, code-behind classique- Pas de cache de navigation (
NavigationCacheMode) SendMessagenon idempotent (msg ajouté avant envoi confirmé)- Regex Markdown fragile (italic dans bold)
new Random()non seedé pour ChatId
Ce qui est bien fait
x:Bind partout
Performance + détection d'erreurs à la compilation.
Toolkit MVVM
Source generators bien utilisés, code minimal.
DispatcherQueue
Systématiquement utilisé pour les accès UI cross-thread.
Propriétés calculées
HasImage, BubblePadding... évite la logique XAML.
Badge "non lu"
Propre, basé sur CollectionChanged.
Mica + UI Fluent
Intégration native Windows 11 correcte.
Roadmap de refactor
🥇 Priorité 1 — Stabilité (cette semaine)
- Décoder les médias en async dans le ViewModel au lieu des converters bloquants. Stocker
BitmapImage?etMediaSource?directement dansChatMessage. - Reconnexion WebSocket avec backoff exponentiel dans
ReceiveLoopAsync:if (!token.IsCancellationRequested) { LogMessage("Connection lost. Retrying..."); await Task.Delay(_retryDelay, token); _retryDelay = Math.Min(_retryDelay * 2, 60000); _ = ConnectWebSocketAsync(); } - Marquer les messages "en attente" jusqu'à confirmation d'envoi côté WS, et permettre le retry.
🥈 Priorité 2 — Architecture (les semaines suivantes)
- Découper MainViewModel en 3 services injectés :
public partial class MainViewModel : ObservableObject { private readonly IChatTransport _transport; private readonly IAudioRecorder _audio; private readonly StorageService _storage; } - SettingsViewModel avec bindings TwoWay, supprime le code-behind.
- Cache de navigation sur ChatPage :
<Page NavigationCacheMode="Required">
🥉 Priorité 3 — Features & qualité (après stabilisation)
- Markdown plus riche : blocs de code (```), titres, listes. Ou intégrer Markdig + un renderer XAML.
- Tests unitaires sur les services et le ViewModel (possible une fois découpé).
- Indicateur de statut WebSocket dans l'UI (connecté/déconnecté/reconnexion).
- Streaming des réponses caractère par caractère (modifier
ChatMessage.Textau fur et à mesure).
Guide de reprise — où chercher quoi
| Question | Fichier(s) à ouvrir |
|---|---|
| Pourquoi ce message s'affiche comme ça ? | ChatPage.xaml (DataTemplate) + ChatMessage.cs (propriétés calculées) |
| Modifier les couleurs des bulles | Converters/ChatConverters.cs (BackgroundConverter, ForegroundConverter) |
| Comment le markdown est rendu | Converters/MarkdownText.cs |
| Ajouter un nouveau type de message | ChatMessage.cs + DataTemplate dans ChatPage.xaml |
| Ajouter un champ dans les settings | SettingsPage.xaml + .xaml.cs + StorageService.AppConfig + MainViewModel |
| Comprendre la reconnexion WS | MainViewModel.SaveSettings() & ConnectWebSocketAsync() |
| Synchroniser le volume | ChatPage.ViewModel_PropertyChanged & ApplyVolume |
| Calculer la durée d'un audio | ChatPage.TryHookDuration + ChatMessage.Duration |
| Le badge "non lu" | MainWindow.Messages_CollectionChanged + RootFrame_Navigated |
Cycle de modification typique
Ajouter une nouvelle propriété observable au ViewModel
- Ajouter
[ObservableProperty] private T _name;dans MainViewModel.cs - Binder dans le XAML :
{x:Bind ViewModel.Name, Mode=OneWay} - Si conversion nécessaire : créer un converter dans ChatConverters.cs et le déclarer dans App.xaml
Ajouter une nouvelle page
- Créer MaPage.xaml + MaPage.xaml.cs
- Ajouter
public MainViewModel ViewModel => App.ViewModel;dans le code-behind - Ajouter un
NavigationViewItem Tag="MaPage"dans MainWindow.xaml - Ajouter le cas dans
MainNav_ItemInvokeddu MainWindow.xaml.cs
Modifier la structure d'un message persisté
- Modifier ChatMessage.cs (ajouter/modifier propriété)
- Mettre à jour le DataTemplate dans ChatPage.xaml
- Si le champ vient du serveur : mettre à jour
WebSocketResponsedans MainViewModel.cs - ⚠️ Vérifier la rétrocompat de désérialisation JSON pour les vieux
history.json
Pièges à éviter
- Modifier
Messagesdepuis un thread non-UI sansDispatcherQueue.TryEnqueue→ crash - Oublier
InitializeWithWindow.Initialize(picker, hWnd)avant unFileOpenPicker→ crash silencieux WinUI 3 - Oublier
XamlRoot = this.XamlRootsur unContentDialog→ exception - Oublier
partialsur une classe avec[ObservableProperty]→ ne compile pas - Oublier
Mode=OneWaysur unx:Bindde propriété dynamique → l'UI ne se rafraîchit pas - Oublier
x:DataTypedans unDataTemplate→x:Bindne compile pas
Documentation générée pour le projet alfred-chat · Onboarding + Plongée technique
Tout est lié au vrai code, dans le vrai repo. Quand le code change, mets-moi à jour 💚