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 ».

WinUI 3 .NET 8 C# 12 MVVM WebSocket MSIX

🌱 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.

👤 Toi 💬 Alfred Chat App Windows (WinUI 3) le projet que tu lis 🤖 Alfred Agent IA Python (un autre projet) clavier/souris WebSocket (JSON sur ws://)
Le projet « alfred-chat » = la boîte rose au milieu. Alfred lui-même = autre projet, autre repo.

Ton app sait :

À quoi ça ressemble (l'UI)

📷 Alfred Chat × 💬 Chat 📄 Logs ⚙️ Bonjour ! Comment puis-je t'aider ? 10:32 Donne-moi une recette de gâteau au chocolat 10:33 Voici une recette : utilise du chocolat noir 70%, du beurre, 3 œufs et 150g de sucre. Bon appétit ! 10:33 Alfred is thinking... + Type a message...
L'app : un menu à gauche (3 onglets), une zone de messages au centre, une barre d'input en bas.

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.

VIEW Ce que l'user voit ChatPage.xaml LogsPage.xaml SettingsPage.xaml MainWindow.xaml .xaml + .xaml.cs VIEWMODEL L'état + la logique MainViewModel • Messages (ObservableCol) • InputText • IsWaitingForReply • SendMessageCommand • WebSocket interne • ApiKey / TargetUrl • Volume, audio recording App.ViewModel (singleton) MODEL Les données ChatMessage Text, Sender, Image… SERVICES I/O externe StorageService JSON read/write x:Bind INPC
View ↔ ViewModel ↔ Model. Le ViewModel ne connaît jamais la View.
💡 Pourquoi cette séparation ?
Tu peux changer l'UI (ex. ajouter une page) sans toucher au ViewModel. Tu peux changer le ViewModel (ex. utiliser HTTP au lieu de WebSocket) sans toucher à l'UI. C'est ce qui rend une codebase maintenable sur le long terme.

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 :

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)

📁 alfred-chat/ 📄 alfred-chat.csproj ← config du projet, packages NuGet 📄 App.xaml + App.xaml.cs ← point d'entrée, ressources globales, ViewModel singleton 📄 MainWindow.xaml + .cs ← fenêtre shell, NavigationView, barre de titre 📄 ChatPage.xaml + .cs ← page chat (le gros morceau visuel) 📄 LogsPage.xaml + .cs ← page logs (très simple) 📄 SettingsPage.xaml + .cs ← page settings 📁 ViewModels/ 📄 MainViewModel.cs ← LE ViewModel (440 lignes, fait tout) 📁 Models/ 📄 ChatMessage.cs ← un message (Text, Sender, Image, Audio…) 📁 Services/ 📄 StorageService.cs ← lit/écrit config.json + history.json 📁 Converters/ 📄 ChatConverters.cs ← 9 convertisseurs pour les bindings XAML 📄 MarkdownText.cs ← rendu markdown dans les bulles incoming
⚠️ Bon à savoir
Le projet est plat. Il y a 4 dossiers (ViewModels, Models, Services, Converters) et 7 fichiers à la racine. Tout le reste, c'est des images et des artifacts de build. Tu fais le tour du code en 1 heure.

Que se passe-t-il quand l'utilisateur clique « Envoyer » ?

C'est le flux à comprendre. Tout le reste en découle.

XAML (View) Code-behind MainViewModel WebSocket / Storage UI (résultat) 1 User clique le bouton ➤ 2 SendMessageCommand est invoquée (x:Bind) 3 SendMessage() async crée ChatMessage 4 Messages.Add(msg) vide InputText 5 SaveHistoryAsync() → history.json 6 _webSocket.SendAsync JSON {text, image_b64…} 7 IsWaitingForReply = true (via DispatcherQueue) 8 "Alfred is thinking..." 9 Bulle ajoutée en bas de la liste
Le swim lane du flux d'envoi : 9 étapes, 5 couloirs.
✅ Ce que tu dois retenir
Tout passe par le ViewModel. La View ne fait que déclencher (binding de commande) et refléter (binding de propriété). C'est le pattern MVVM pur.

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'UICréer un IValueConverter dans Converters/
Afficher/cacher un élémentVisibility="{x:Bind Cond, Converter=...}"
Aligner à gauche ou droiteHorizontalAlignment="{x:Bind IsOutgoing, Converter=AlignmentConverter}"
Naviguer vers une pageRootFrame.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();
    }
}
🎯 Décision architecturale clé
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 :

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.

📌 Détail intéressant
Il y a deux TextBlock pour le texte dans le template : un pour les messages sortants (plain text), un pour les entrants (markdown via 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 :

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 :

  1. Volume global : quand ViewModel.Volume change, on applique sur tous les players
  2. Durée du média : on hook MediaPlayer.MediaOpened pour récupérer la durée et la stocker dans ChatMessage.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

MainViewModel : ObservableObject 🔹 État observable InputText : string IsWaitingForReply : bool PendingImageBase64 : string? PendingAudioBase64 : string? IsRecording, Volume, ChatId… 🔸 Collections Messages : ObservableCollection <ChatMessage> AppLogs : ObservableCollection <string> ⚙️ Services internes _webSocket : ClientWebSocket _mediaCapture : MediaCapture _storageService : StorageService _dispatcherQueue, _cts 🚀 Méthodes [RelayCommand] SendMessage() PickImageAsync() Start/StopRecordingAsync() ConnectWebSocketAsync() ReceiveLoopAsync()
Quatre familles de membres dans MainViewModel. À découper en plusieurs classes un jour.

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();
        });
    }
}
⚠️ La boucle do-while sur EndOfMessage
Un message WebSocket peut être fragmenté en plusieurs frames. La boucle accumule les bytes dans un MemoryStream jusqu'au flag 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 :

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.

🚨 Problème caché
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.

ConverterEntréeSortieUsage
AlignmentConverterboolHorizontalAlignmentBulle gauche/droite
BackgroundConverterboolBrushFond accent/neutre
ForegroundConverterboolBrushTexte clair/foncé
BorderBrushConverterboolBrushBordure visible/invisible
VisibilityConverterboolVisibilitytrue → Visible
InverseVisibilityConverterboolVisibilitytrue → Collapsed
VolumePercentConverterdoublestring0.75 → "75%"
Base64ImageConverterstringBitmapImageImage depuis base64
Base64MediaSourceConverterstringMediaSourceAudio depuis base64
🚨 Bug latent dans les converters 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

🎨 Thread UI (un seul !) XAML rendering Event handlers (clicks, keydown) Updates ObservableCollection Binding propagation (INPC) Tout ce qui touche à l'UI doit être ici ⚙️ Thread Pool (background) await _webSocket.ReceiveAsync() await FileIO.WriteTextAsync() JsonSerializer.Deserialize await ConnectAsync() I/O et calculs lourds vont ici DispatcherQueue .TryEnqueue()
Le DispatcherQueue est le pont entre les threads de pool et le thread UI sacré.

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;
});
📌 Règle d'or
Toute modification d'une 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}
ÉvaluationCompile-timeRuntime
SourcePage (chemin direct)DataContext (hérité)
Mode par défautOneTimeOneWay
PerformancePlus rapidePlus lent (reflection)
ErreursÀ la compilationÀ runtime, silencieuses
Nécessite x:DataTypeOui dans DataTemplateNon

Ce projet utilise x:Bind partout. C'est le bon choix moderne.

⚠️ Piège classique
Le mode par défaut de 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 : MainViewModel fait 440 lignes et gère 6 responsabilités
  • GetAwaiter().GetResult() bloquant dans les converters Base64
  • Pas de reconnexion auto si WebSocket coupe
  • SaveHistoryAsync fire-and-forget sans gestion d'erreur
  • Pas de CancellationToken à l'arrêt de l'app

⚠️ Modérés

  • SettingsPage sans binding, code-behind classique
  • Pas de cache de navigation (NavigationCacheMode)
  • SendMessage non 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)

  1. Décoder les médias en async dans le ViewModel au lieu des converters bloquants. Stocker BitmapImage? et MediaSource? directement dans ChatMessage.
  2. 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();
    }
  3. Marquer les messages "en attente" jusqu'à confirmation d'envoi côté WS, et permettre le retry.

🥈 Priorité 2 — Architecture (les semaines suivantes)

  1. 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;
    }
  2. SettingsViewModel avec bindings TwoWay, supprime le code-behind.
  3. Cache de navigation sur ChatPage :
    <Page NavigationCacheMode="Required">

🥉 Priorité 3 — Features & qualité (après stabilisation)

  1. Markdown plus riche : blocs de code (```), titres, listes. Ou intégrer Markdig + un renderer XAML.
  2. Tests unitaires sur les services et le ViewModel (possible une fois découpé).
  3. Indicateur de statut WebSocket dans l'UI (connecté/déconnecté/reconnexion).
  4. Streaming des réponses caractère par caractère (modifier ChatMessage.Text au fur et à mesure).

Guide de reprise — où chercher quoi

QuestionFichier(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
  1. Ajouter [ObservableProperty] private T _name; dans MainViewModel.cs
  2. Binder dans le XAML : {x:Bind ViewModel.Name, Mode=OneWay}
  3. Si conversion nécessaire : créer un converter dans ChatConverters.cs et le déclarer dans App.xaml
Ajouter une nouvelle page
  1. Créer MaPage.xaml + MaPage.xaml.cs
  2. Ajouter public MainViewModel ViewModel => App.ViewModel; dans le code-behind
  3. Ajouter un NavigationViewItem Tag="MaPage" dans MainWindow.xaml
  4. Ajouter le cas dans MainNav_ItemInvoked du MainWindow.xaml.cs
Modifier la structure d'un message persisté
  1. Modifier ChatMessage.cs (ajouter/modifier propriété)
  2. Mettre à jour le DataTemplate dans ChatPage.xaml
  3. Si le champ vient du serveur : mettre à jour WebSocketResponse dans MainViewModel.cs
  4. ⚠️ Vérifier la rétrocompat de désérialisation JSON pour les vieux history.json

Pièges à éviter

  • Modifier Messages depuis un thread non-UI sans DispatcherQueue.TryEnqueue → crash
  • Oublier InitializeWithWindow.Initialize(picker, hWnd) avant un FileOpenPicker → crash silencieux WinUI 3
  • Oublier XamlRoot = this.XamlRoot sur un ContentDialog → exception
  • Oublier partial sur une classe avec [ObservableProperty] → ne compile pas
  • Oublier Mode=OneWay sur un x:Bind de propriété dynamique → l'UI ne se rafraîchit pas
  • Oublier x:DataType dans un DataTemplatex:Bind ne 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 💚