scripts/publish.sh crea in dist/ gli eseguibili single-file per win-x64 e linux-x64 (o i rid indicati) e toglie i simboli delle librerie native; dist/ è ignorata da git. Ogni motore dichiara DefaultModelId (Haiku 5.5 per Claude Code, GPT-5.6 Luna per Codex) e il ViewModel parte da quel modello con effort low, all'avvio e a ogni cambio di CLI; "Predefinito" resta scelta possibile. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
324 lines
13 KiB
C#
324 lines
13 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Windows.Input;
|
|
using AgentWikiUi.Core;
|
|
|
|
namespace AgentWikiUi.Gui;
|
|
|
|
class MessageVm(string role, string text, string author)
|
|
{
|
|
public string Role { get; } = role;
|
|
public string Text { get; } = text;
|
|
public string Author { get; } = author;
|
|
public bool IsUser => Role == "user";
|
|
public bool IsAi => Role == "ai";
|
|
public bool IsErr => Role == "err";
|
|
}
|
|
|
|
class ChatItem(string id, string title, DateTime updated, bool active, string engineName)
|
|
{
|
|
public string Id { get; } = id;
|
|
public string Title { get; } = title;
|
|
public string When { get; } = $"{engineName} · {updated:d MMM HH:mm}";
|
|
public bool IsActive { get; } = active;
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
string version = "";
|
|
string status = "";
|
|
bool statusIsError;
|
|
bool? available;
|
|
string? latest;
|
|
|
|
public Engine Engine { get; } = engine;
|
|
public bool Installed { get; } = engine.IsInstalled();
|
|
public string Name => Engine.Name;
|
|
public string Label => Installed ? Engine.Name : $"{Engine.Name} (non installato)";
|
|
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;
|
|
}
|
|
}
|
|
|
|
class MainViewModel : ObservableObject
|
|
{
|
|
static readonly ModelOption Default = new("Predefinito", "");
|
|
static readonly ModelOption Custom = new("Altro ID…", "__custom");
|
|
const string NoEffort = "Predefinito";
|
|
|
|
public ObservableCollection<MessageVm> Messages { get; } = [];
|
|
public ObservableCollection<ChatItem> Chats { get; } = [];
|
|
public EngineChoice[] EngineChoices { get; } = Engines.All.Select(e => new EngineChoice(e)).ToArray();
|
|
/// <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);
|
|
|
|
public ICommand NewChatCommand => newChat;
|
|
public ICommand CopyCommand { get; }
|
|
public ICommand SendCommand { get; }
|
|
public ICommand OpenCommand { get; }
|
|
public ICommand DeleteCommand { get; }
|
|
public ICommand ToggleSidebarCommand { get; }
|
|
public ICommand UpdateCommand { get; }
|
|
|
|
/// <summary>Conferma di eliminazione, fornita dalla finestra.</summary>
|
|
public Func<Task<bool>> ConfirmDelete { get; set; } = () => Task.FromResult(true);
|
|
/// <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;
|
|
|
|
// 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
|
|
List<Msg> history = [];
|
|
|
|
EngineChoice selectedEngine;
|
|
ModelOption[] modelChoices = [];
|
|
string[] effortChoices = [];
|
|
string input = "";
|
|
bool busy, updating, sidebarOpen = true;
|
|
ModelOption selectedModel = Default;
|
|
string customModel = "";
|
|
string selectedEffort = NoEffort;
|
|
readonly RelayCommand<object> send, newChat;
|
|
|
|
public MainViewModel()
|
|
{
|
|
selectedEngine = EngineChoices.FirstOrDefault(c => c.Installed) ?? EngineChoices[0];
|
|
ApplyEngine(selectedEngine);
|
|
send = new(_ => _ = SendAsync(), () => CanSend);
|
|
SendCommand = send;
|
|
newChat = new(_ => NewChat(), () => !busy && !updating);
|
|
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);
|
|
UpdateCommand = new RelayCommand<EngineChoice>(c => { if (c is not null) _ = UpdateEngineAsync(c); });
|
|
Messages.CollectionChanged += (_, _) => { Raise(nameof(HasMessages)); Raise(nameof(ShowEmpty)); };
|
|
ResetSession();
|
|
|
|
_ = CheckUpdatesAsync();
|
|
|
|
// all'avvio riapre la chat più recente
|
|
if (ChatStore.List().FirstOrDefault() is { } last) OpenChat(last.Id); else RefreshChats();
|
|
}
|
|
|
|
Engine Engine => selectedEngine.Engine;
|
|
|
|
public bool HasMessages => Messages.Count > 0;
|
|
public bool ShowEmpty => Messages.Count == 0;
|
|
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;
|
|
public string Author => Engine.Author;
|
|
public string InputPlaceholder => $"Scrivi un messaggio a {Engine.Author}…";
|
|
public string BusyText => $"{Engine.Author} sta scrivendo…";
|
|
|
|
public string Input
|
|
{
|
|
get => input;
|
|
set { if (Set(ref input, value)) { Raise(nameof(CanSend)); send.Refresh(); } }
|
|
}
|
|
|
|
public bool IsBusy
|
|
{
|
|
get => busy;
|
|
private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); Raise(nameof(CanSwitchEngine)); Raise(nameof(CanUpdate)); send.Refresh(); newChat.Refresh(); } }
|
|
}
|
|
|
|
public bool SidebarOpen { get => sidebarOpen; set => Set(ref sidebarOpen, value); }
|
|
|
|
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;
|
|
|
|
/// <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
|
|
{
|
|
if (value is null || value == selectedEngine || busy || updating || !value.Installed) return;
|
|
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); }
|
|
|
|
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); }
|
|
|
|
// 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];
|
|
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;
|
|
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 = [];
|
|
}
|
|
|
|
void RefreshChats()
|
|
{
|
|
Chats.Clear();
|
|
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));
|
|
}
|
|
|
|
void Show(List<Msg> msgs, string author)
|
|
{
|
|
Messages.Clear();
|
|
foreach (var m in msgs) Messages.Add(new(m.Role, m.Text, author));
|
|
}
|
|
|
|
void NewChat()
|
|
{
|
|
ResetSession();
|
|
Show(history, Author); RefreshChats(); FocusInput?.Invoke();
|
|
}
|
|
|
|
void OpenChat(string id)
|
|
{
|
|
if (busy) return;
|
|
if (ChatStore.Load(id) is not { } chat) { RefreshChats(); return; }
|
|
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();
|
|
}
|
|
|
|
async Task DeleteAsync(ChatItem c)
|
|
{
|
|
if (busy || !await ConfirmDelete()) return;
|
|
ChatStore.Delete(c.Id);
|
|
if (c.Id == chatId) NewChat(); else RefreshChats();
|
|
}
|
|
|
|
async Task SendAsync()
|
|
{
|
|
if (!CanSend) return;
|
|
var text = input.Trim();
|
|
var model = IsCustomModel ? customModel.Trim() : selectedModel.Id;
|
|
var effort = selectedEffort == NoEffort ? "" : selectedEffort;
|
|
|
|
Input = ""; IsBusy = true;
|
|
Add("user", text);
|
|
Persist(); // il messaggio non va perso se l'app si chiude durante l'attesa
|
|
RefreshChats();
|
|
try
|
|
{
|
|
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);
|
|
}
|
|
catch (System.ComponentModel.Win32Exception) { Add("err", $"'{Engine.Executable}' non trovato nel PATH."); }
|
|
catch (Exception ex) { Add("err", ex.Message); }
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
Persist();
|
|
RefreshChats();
|
|
FocusInput?.Invoke();
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text, Author)); }
|
|
|
|
void Persist()
|
|
{
|
|
try { ChatStore.Save(chatId, Engine.Id, sessionId, started, history); }
|
|
catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce
|
|
}
|
|
}
|