2026-10-15 11:18:46 +02:00
using System.Collections.ObjectModel ;
using System.Windows.Input ;
using AgentWikiUi.Core ;
namespace AgentWikiUi.Gui ;
2026-10-15 11:40:47 +02:00
class MessageVm ( string role , string text , string author )
2026-10-15 11:18:46 +02:00
{
public string Role { get ; } = role ;
public string Text { get ; } = text ;
2026-10-15 11:40:47 +02:00
public string Author { get ; } = author ;
2026-10-15 11:18:46 +02:00
public bool IsUser => Role == "user" ;
public bool IsAi => Role == "ai" ;
public bool IsErr => Role == "err" ;
}
2026-10-15 11:40:47 +02:00
class ChatItem ( string id , string title , DateTime updated , bool active , string engineName )
2026-10-15 11:18:46 +02:00
{
public string Id { get ; } = id ;
public string Title { get ; } = title ;
2026-10-15 11:40:47 +02:00
public string When { get ; } = $"{engineName} · {updated:d MMM HH:mm}" ;
2026-10-15 11:18:46 +02:00
public bool IsActive { get ; } = active ;
}
2026-10-15 11:49:22 +02:00
/// <summary>Voce dell'interruttore CLI: un motore non installato è mostrato ma non selezionabile. Porta anche versione ed esito dell'ultimo aggiornamento.</summary>
class EngineChoice ( Engine engine ) : ObservableObject
2026-10-15 11:40:47 +02:00
{
2026-10-15 11:49:22 +02:00
string version = "" ;
string status = "" ;
bool statusIsError ;
bool? available ;
string? latest ;
2026-10-15 11:40:47 +02:00
public Engine Engine { get ; } = engine ;
public bool Installed { get ; } = engine . IsInstalled ();
2026-10-15 11:49:22 +02:00
public string Name => Engine . Name ;
2026-10-15 11:40:47 +02:00
public string Label => Installed ? Engine . Name : $"{Engine.Name} (non installato)" ;
2026-10-15 11:49:22 +02:00
public string Version { get => version ; set => Set ( ref version , value ); }
public string Status { get => status ; set { if ( Set ( ref status , value )) Raise ( nameof ( HasStatus )); } }
public bool HasStatus => status != "" ;
public bool StatusIsError { get => statusIsError ; set => Set ( ref statusIsError , value ); }
/// <summary>Esito del controllo: true = c'è una versione nuova, false = aggiornato, null = non controllato o sconosciuto.</summary>
public bool? Available { get => available ; private set { if ( Set ( ref available , value )) { Raise ( nameof ( UpdateAvailable )); Raise ( nameof ( CheckText )); } } }
public bool UpdateAvailable => available == true ;
public string CheckText => available switch
{
true => $"Aggiornamento disponibile: {latest}" ,
false => "Aggiornato" ,
null => checkedOnce ? "Controllo non riuscito" : "" ,
};
bool checkedOnce ;
public void SetCheck ( bool? isAvailable , string? latestVersion )
{
latest = latestVersion ; checkedOnce = true ;
Raise ( nameof ( CheckText ));
Available = isAvailable ;
}
2026-10-15 11:40:47 +02:00
}
2026-10-15 11:18:46 +02:00
class MainViewModel : ObservableObject
{
static readonly ModelOption Default = new ( "Predefinito" , "" );
static readonly ModelOption Custom = new ( "Altro ID…" , "__custom" );
2026-10-15 12:03:03 +02:00
const string NoEffort = "Predefinito" ;
2026-10-15 11:18:46 +02:00
public ObservableCollection < MessageVm > Messages { get ; } = [];
public ObservableCollection < ChatItem > Chats { get ; } = [];
2026-10-15 11:40:47 +02:00
public EngineChoice [] EngineChoices { get ; } = Engines . All . Select ( e => new EngineChoice ( e )). ToArray ();
2026-10-15 11:49:22 +02:00
/// <summary>Righe della sezione Aggiornamenti: solo i CLI installati.</summary>
public EngineChoice [] InstalledEngines => EngineChoices . Where ( c => c . Installed ). ToArray ();
/// <summary>Puntino sul pulsante Impostazioni quando almeno un CLI ha una versione nuova.</summary>
public bool AnyUpdateAvailable => EngineChoices . Any ( c => c . UpdateAvailable );
2026-10-15 11:18:46 +02:00
public ICommand NewChatCommand => newChat ;
public ICommand CopyCommand { get ; }
public ICommand SendCommand { get ; }
public ICommand OpenCommand { get ; }
public ICommand DeleteCommand { get ; }
public ICommand ToggleSidebarCommand { get ; }
2026-10-15 11:49:22 +02:00
public ICommand UpdateCommand { get ; }
2026-10-15 11:18:46 +02:00
/// <summary>Conferma di eliminazione, fornita dalla finestra.</summary>
2026-10-15 11:40:47 +02:00
public Func < Task < bool >> ConfirmDelete { get ; set ; } = () => Task . FromResult ( true );
2026-10-15 11:18:46 +02:00
/// <summary>Copia negli appunti, fornita dalla finestra.</summary>
public Func < string , Task > CopyText { get ; set ; } = _ => Task . CompletedTask ;
/// <summary>Chiesto alla finestra di rimettere il fuoco sul campo messaggio.</summary>
public event Action ? FocusInput ;
2026-10-15 11:40:47 +02:00
// Una chat = un motore. chatId è il nome del file; sessionId è l'id del motore:
// per Claude Code lo sceglie l'app (= chatId), per Codex arriva dall'output dopo il primo messaggio.
string chatId = Guid . NewGuid (). ToString ();
string? sessionId ;
bool started ; // true dopo il primo messaggio riuscito: da lì in poi si riprende la sessione
2026-10-15 11:18:46 +02:00
List < Msg > history = [];
2026-10-15 11:40:47 +02:00
EngineChoice selectedEngine ;
ModelOption [] modelChoices = [];
string [] effortChoices = [];
2026-10-15 11:18:46 +02:00
string input = "" ;
2026-10-15 11:49:22 +02:00
bool busy , updating , sidebarOpen = true ;
2026-10-15 11:18:46 +02:00
ModelOption selectedModel = Default ;
string customModel = "" ;
2026-10-15 12:03:03 +02:00
string selectedEffort = NoEffort ;
2026-10-15 11:18:46 +02:00
readonly RelayCommand < object > send , newChat ;
public MainViewModel ()
{
2026-10-15 11:40:47 +02:00
selectedEngine = EngineChoices . FirstOrDefault ( c => c . Installed ) ?? EngineChoices [ 0 ];
ApplyEngine ( selectedEngine );
2026-10-15 11:18:46 +02:00
send = new ( _ => _ = SendAsync (), () => CanSend );
SendCommand = send ;
2026-10-15 11:49:22 +02:00
newChat = new ( _ => NewChat (), () => ! busy && ! updating );
2026-10-15 11:18:46 +02:00
CopyCommand = new RelayCommand < string >( t => { if ( t is not null ) _ = CopyText ( t ); });
OpenCommand = new RelayCommand < ChatItem >( c => { if ( c is not null ) OpenChat ( c . Id ); });
DeleteCommand = new RelayCommand < ChatItem >( c => { if ( c is not null ) _ = DeleteAsync ( c ); });
ToggleSidebarCommand = new RelayCommand < object >( _ => SidebarOpen = ! SidebarOpen );
2026-10-15 11:49:22 +02:00
UpdateCommand = new RelayCommand < EngineChoice >( c => { if ( c is not null ) _ = UpdateEngineAsync ( c ); });
2026-10-15 11:18:46 +02:00
Messages . CollectionChanged += ( _ , _ ) => { Raise ( nameof ( HasMessages )); Raise ( nameof ( ShowEmpty )); };
2026-10-15 11:40:47 +02:00
ResetSession ();
2026-10-15 11:18:46 +02:00
2026-10-15 11:49:22 +02:00
_ = CheckUpdatesAsync ();
2026-10-15 11:18:46 +02:00
// all'avvio riapre la chat più recente
if ( ChatStore . List (). FirstOrDefault () is { } last ) OpenChat ( last . Id ); else RefreshChats ();
}
2026-10-15 11:40:47 +02:00
Engine Engine => selectedEngine . Engine ;
2026-10-15 11:18:46 +02:00
public bool HasMessages => Messages . Count > 0 ;
public bool ShowEmpty => Messages . Count == 0 ;
2026-10-15 11:49:22 +02:00
public bool CanSend => ! busy && ! updating && ! string . IsNullOrWhiteSpace ( input );
/// <summary>I pulsanti Aggiorna sono attivi solo se non c'è una risposta né un altro aggiornamento in corso.</summary>
public bool CanUpdate => ! busy && ! updating ;
2026-10-15 11:40:47 +02:00
public string Author => Engine . Author ;
public string InputPlaceholder => $"Scrivi un messaggio a {Engine.Author}…" ;
public string BusyText => $"{Engine.Author} sta scrivendo…" ;
2026-10-15 11:18:46 +02:00
public string Input
{
get => input ;
set { if ( Set ( ref input , value )) { Raise ( nameof ( CanSend )); send . Refresh (); } }
}
public bool IsBusy
{
get => busy ;
2026-10-15 11:49:22 +02:00
private set { if ( Set ( ref busy , value )) { Raise ( nameof ( CanSend )); Raise ( nameof ( CanSwitchEngine )); Raise ( nameof ( CanUpdate )); send . Refresh (); newChat . Refresh (); } }
2026-10-15 11:18:46 +02:00
}
public bool SidebarOpen { get => sidebarOpen ; set => Set ( ref sidebarOpen , value ); }
2026-10-15 11:40:47 +02:00
2026-10-15 11:49:22 +02:00
bool IsUpdating
{
get => updating ;
set { if ( Set ( ref updating , value )) { Raise ( nameof ( CanSend )); Raise ( nameof ( CanSwitchEngine )); Raise ( nameof ( CanUpdate )); send . Refresh (); newChat . Refresh (); } }
}
public bool CanSwitchEngine => ! busy && ! updating ;
2026-10-15 11:40:47 +02:00
/// <summary>Interruttore CLI: cambiare motore apre una nuova chat (il contesto non passa da un motore all'altro).</summary>
public EngineChoice SelectedEngine
{
get => selectedEngine ;
set
{
2026-10-15 11:49:22 +02:00
if ( value is null || value == selectedEngine || busy || updating || ! value . Installed ) return ;
2026-10-15 11:40:47 +02:00
ApplyEngine ( value );
NewChat ();
}
}
public ModelOption [] ModelChoices { get => modelChoices ; private set => Set ( ref modelChoices , value ); }
public string [] EffortChoices { get => effortChoices ; private set => Set ( ref effortChoices , value ); }
2026-10-15 11:18:46 +02:00
public ModelOption SelectedModel
{
get => selectedModel ;
set { if ( Set ( ref selectedModel , value )) Raise ( nameof ( IsCustomModel )); }
}
public bool IsCustomModel => selectedModel == Custom ;
public string CustomModel { get => customModel ; set => Set ( ref customModel , value ); }
public string SelectedEffort { get => selectedEffort ; set => Set ( ref selectedEffort , value ); }
2026-10-15 11:40:47 +02:00
// Adotta un motore: liste di modelli ed effort sue, scelte riportate a "Predefinito".
void ApplyEngine ( EngineChoice choice )
{
selectedEngine = choice ;
ModelChoices = [ Default , .. choice . Engine . Models , Custom ];
2026-10-15 12:03:03 +02:00
EffortChoices = [ NoEffort , .. choice . Engine . Efforts ];
// valori iniziali: il modello più piccolo del motore ed effort basso ("Predefinito" resta selezionabile)
SelectedModel = ModelChoices . FirstOrDefault ( m => m . Id == choice . Engine . DefaultModelId ) ?? Default ;
CustomModel = "" ; SelectedEffort = choice . Engine . DefaultEffort ;
2026-10-15 11:40:47 +02:00
Raise ( nameof ( SelectedEngine )); Raise ( nameof ( Author )); Raise ( nameof ( InputPlaceholder )); Raise ( nameof ( BusyText ));
}
void ResetSession ()
{
chatId = Guid . NewGuid (). ToString ();
sessionId = Engine . OwnsSessionId ? null : chatId ;
started = false ;
history = [];
}
2026-10-15 11:18:46 +02:00
void RefreshChats ()
{
Chats . Clear ();
2026-10-15 11:40:47 +02:00
foreach ( var c in ChatStore . List ())
Chats . Add ( new ( c . Id , c . Title , c . Updated , c . Id == chatId , Engines . Find ( c . EngineId )?. Name ?? c . EngineId ));
2026-10-15 11:18:46 +02:00
}
2026-10-15 11:40:47 +02:00
void Show ( List < Msg > msgs , string author )
2026-10-15 11:18:46 +02:00
{
Messages . Clear ();
2026-10-15 11:40:47 +02:00
foreach ( var m in msgs ) Messages . Add ( new ( m . Role , m . Text , author ));
2026-10-15 11:18:46 +02:00
}
void NewChat ()
{
2026-10-15 11:40:47 +02:00
ResetSession ();
Show ( history , Author ); RefreshChats (); FocusInput ?. Invoke ();
2026-10-15 11:18:46 +02:00
}
void OpenChat ( string id )
{
if ( busy ) return ;
if ( ChatStore . Load ( id ) is not { } chat ) { RefreshChats (); return ; }
2026-10-15 11:40:47 +02:00
var choice = EngineChoices . FirstOrDefault ( c => c . Engine . Id == chat . EngineId );
if ( choice is not null && choice != selectedEngine ) ApplyEngine ( choice );
chatId = chat . Id ; sessionId = chat . EffectiveSessionId ; started = chat . Started ; history = chat . Messages ;
Show ( history , Author ); RefreshChats ();
2026-10-15 11:18:46 +02:00
}
async Task DeleteAsync ( ChatItem c )
{
if ( busy || ! await ConfirmDelete ()) return ;
ChatStore . Delete ( c . Id );
2026-10-15 11:40:47 +02:00
if ( c . Id == chatId ) NewChat (); else RefreshChats ();
2026-10-15 11:18:46 +02:00
}
async Task SendAsync ()
{
if (! CanSend ) return ;
var text = input . Trim ();
var model = IsCustomModel ? customModel . Trim () : selectedModel . Id ;
2026-10-15 12:03:03 +02:00
var effort = selectedEffort == NoEffort ? "" : selectedEffort ;
2026-10-15 11:18:46 +02:00
Input = "" ; IsBusy = true ;
Add ( "user" , text );
Persist (); // il messaggio non va perso se l'app si chiude durante l'attesa
RefreshChats ();
try
{
2026-10-15 11:40:47 +02:00
var r = await EngineRunner . RunCapturedAsync ( Engine , text , sessionId , started , model , effort );
if ( r . Error is null )
{
started = true ;
if ( r . SessionId is not null ) sessionId = r . SessionId ;
Add ( "ai" , r . Text );
}
else Add ( "err" , r . Error );
2026-10-15 11:18:46 +02:00
}
2026-10-15 11:40:47 +02:00
catch ( System . ComponentModel . Win32Exception ) { Add ( "err" , $"'{Engine.Executable}' non trovato nel PATH." ); }
2026-10-15 11:18:46 +02:00
catch ( Exception ex ) { Add ( "err" , ex . Message ); }
finally
{
IsBusy = false ;
Persist ();
RefreshChats ();
FocusInput ?. Invoke ();
}
}
2026-10-15 11:49:22 +02:00
static readonly TimeSpan CheckEvery = TimeSpan . FromMinutes ( 5 );
DateTime lastCheck = DateTime . MinValue ;
bool checking ;
/// <summary>Controlla (sola lettura) se i CLI hanno una versione nuova. All'avvio, all'apertura del riquadro (al massimo ogni 5 minuti) e dopo un aggiornamento.</summary>
public async Task CheckUpdatesAsync ( bool force = false )
{
if ( checking || (! force && DateTime . UtcNow - lastCheck < CheckEvery )) return ;
checking = true ;
try
{
foreach ( var c in InstalledEngines )
{
var r = await EngineUpdater . CheckAsync ( c . Engine );
if ( r . InstalledText is not null ) c . Version = r . InstalledText ;
c . SetCheck ( r . Available , r . Latest );
Raise ( nameof ( AnyUpdateAvailable ));
}
lastCheck = DateTime . UtcNow ;
}
finally { checking = false ; }
}
// Aggiorna un solo CLI con il suo comando ufficiale e mostra l'esito sulla sua riga (ADR-0004).
async Task UpdateEngineAsync ( EngineChoice c )
{
if (! CanUpdate || ! c . Installed ) return ;
IsUpdating = true ;
c . StatusIsError = false ; c . Status = "Aggiornamento in corso…" ;
try
{
var r = await EngineUpdater . UpdateAsync ( c . Engine );
c . StatusIsError = ! r . Ok ;
c . Status = r . Message ;
if ( r . Ok ) c . Version = r . After ?? r . Before ?? c . Version ;
}
catch ( Exception ex ) { c . StatusIsError = true ; c . Status = ex . Message ; }
finally { IsUpdating = false ; }
await CheckUpdatesAsync ( force : true ); // dopo l'aggiornamento: ripete il controllo, il pulsante non resta evidenziato
}
2026-10-15 11:40:47 +02:00
void Add ( string role , string text ) { history . Add ( new ( role , text )); Messages . Add ( new ( role , text , Author )); }
2026-10-15 11:18:46 +02:00
void Persist ()
{
2026-10-15 11:40:47 +02:00
try { ChatStore . Save ( chatId , Engine . Id , sessionId , started , history ); }
2026-10-15 11:18:46 +02:00
catch ( IOException ) { } // la chat resta a schermo anche se il salvataggio fallisce
}
}