Impostazioni: controllo e aggiornamento dei CLI con pulsante evidenziato
Sezione Aggiornamenti nel riquadro Impostazioni: per ogni CLI installato versione, esito del controllo (disponibile, aggiornato, non riuscito) e pulsante Aggiorna che esegue il comando ufficiale. Il pulsante e un puntino sul pulsante Impostazioni si evidenziano solo se c'è una versione nuova. Il controllo legge l'ultima versione dalle sorgenti ufficiali (downloads.claude.ai e release GitHub di Codex), parte all'avvio e all'apertura del riquadro con cache di 5 minuti e non fallisce mai in modo invasivo. L'aggiornamento ha tempo massimo di 10 minuti, blocca invio e cambio CLI, e in caso di errore suggerisce il comando manuale per npm, Homebrew, WinGet o installazione nativa. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6498e45a0d
commit
8b20f5224e
7 files changed
+328
-16
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
namespace AgentWikiUi.Core;
|
namespace AgentWikiUi.Core;
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ abstract class Engine
|
|||||||
/// <param name="structured">true = GUI (output catturato e da interpretare); false = CLI (output ereditato).</param>
|
/// <param name="structured">true = GUI (output catturato e da interpretare); false = CLI (output ereditato).</param>
|
||||||
public abstract List<string> BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured);
|
public abstract List<string> BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured);
|
||||||
public abstract EngineReply Parse(int code, string stdout, string stderr);
|
public abstract EngineReply Parse(int code, string stdout, string stderr);
|
||||||
|
/// <summary>Testo che annuncia l'ultima versione pubblicata (sola lettura, vedi ADR-0004). Eccezioni di rete/formato: le gestisce il chiamante.</summary>
|
||||||
|
public abstract Task<string?> FetchLatestVersionAsync(HttpClient http, CancellationToken ct);
|
||||||
|
|
||||||
public bool IsInstalled() => PathSearch.Find(Executable) is not null;
|
public bool IsInstalled() => PathSearch.Find(Executable) is not null;
|
||||||
|
|
||||||
@@ -60,6 +63,10 @@ sealed class ClaudeEngine : Engine
|
|||||||
|
|
||||||
public override EngineReply Parse(int code, string stdout, string stderr) =>
|
public override EngineReply Parse(int code, string stdout, string stderr) =>
|
||||||
code == 0 ? new(stdout.Trim(), null, null) : new("", null, Failed(code, stderr.Trim()));
|
code == 0 ? new(stdout.Trim(), null, null) : new("", null, Failed(code, stderr.Trim()));
|
||||||
|
|
||||||
|
// Stessa sorgente dell'installer nativo: un file di testo con la versione.
|
||||||
|
public override async Task<string?> FetchLatestVersionAsync(HttpClient http, CancellationToken ct) =>
|
||||||
|
(await http.GetStringAsync("https://downloads.claude.ai/claude-code-releases/latest", ct)).Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CodexEngine : Engine
|
sealed class CodexEngine : Engine
|
||||||
@@ -123,6 +130,17 @@ sealed class CodexEngine : Engine
|
|||||||
return new(lastMessage.Trim(), threadId, null);
|
return new(lastMessage.Trim(), threadId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ultima release su GitHub: tag_name tipo "rust-v0.162.1". L'API richiede lo User-Agent (impostato dal chiamante).
|
||||||
|
public override async Task<string?> FetchLatestVersionAsync(HttpClient http, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/openai/codex/releases/latest");
|
||||||
|
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||||
|
using var resp = await http.SendAsync(req, ct);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
||||||
|
return doc.RootElement.GetProperty("tag_name").GetString();
|
||||||
|
}
|
||||||
|
|
||||||
// L'errore arriva come stringa che contiene un JSON {"error":{"message":…}}: si mostra il messaggio interno.
|
// L'errore arriva come stringa che contiene un JSON {"error":{"message":…}}: si mostra il messaggio interno.
|
||||||
static string? InnerMessage(string? raw)
|
static string? InnerMessage(string? raw)
|
||||||
{
|
{
|
||||||
|
|||||||
+24
-2
@@ -5,7 +5,10 @@ namespace AgentWikiUi.Core;
|
|||||||
// Unico punto di avvio dei motori, condiviso da CLI e GUI.
|
// Unico punto di avvio dei motori, condiviso da CLI e GUI.
|
||||||
static class EngineRunner
|
static class EngineRunner
|
||||||
{
|
{
|
||||||
public static ProcessStartInfo CreateStart(Engine engine, string prompt, bool captured, string? sessionId = null, bool resume = false, string model = "", string effort = "")
|
public static ProcessStartInfo CreateStart(Engine engine, string prompt, bool captured, string? sessionId = null, bool resume = false, string model = "", string effort = "") =>
|
||||||
|
CreateStart(engine, engine.BuildArgs(prompt, sessionId, resume, model, effort, structured: captured), captured);
|
||||||
|
|
||||||
|
public static ProcessStartInfo CreateStart(Engine engine, IEnumerable<string> args, bool captured)
|
||||||
{
|
{
|
||||||
var psi = new ProcessStartInfo(engine.Executable)
|
var psi = new ProcessStartInfo(engine.Executable)
|
||||||
{
|
{
|
||||||
@@ -16,7 +19,7 @@ static class EngineRunner
|
|||||||
RedirectStandardError = captured,
|
RedirectStandardError = captured,
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
};
|
};
|
||||||
foreach (var a in engine.BuildArgs(prompt, sessionId, resume, model, effort, structured: captured)) psi.ArgumentList.Add(a);
|
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||||
return psi;
|
return psi;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,4 +50,23 @@ static class EngineRunner
|
|||||||
await p.WaitForExitAsync();
|
await p.WaitForExitAsync();
|
||||||
return engine.Parse(p.ExitCode, await o, await e);
|
return engine.Parse(p.ExitCode, await o, await e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comandi di servizio del motore (--version, update): output catturato, tempo massimo, nessuna chat.
|
||||||
|
// Win32Exception se l'eseguibile non è nel PATH; allo scadere il processo e i suoi figli sono terminati.
|
||||||
|
public static async Task<(int Code, string Out, string Err, bool TimedOut)> RunToolAsync(Engine engine, IEnumerable<string> args, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var p = Process.Start(CreateStart(engine, args, captured: true))!;
|
||||||
|
p.StandardInput.Close();
|
||||||
|
var o = p.StandardOutput.ReadToEndAsync();
|
||||||
|
var e = p.StandardError.ReadToEndAsync();
|
||||||
|
using var cts = new CancellationTokenSource(timeout);
|
||||||
|
try { await p.WaitForExitAsync(cts.Token); }
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
try { p.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
|
||||||
|
await p.WaitForExitAsync();
|
||||||
|
return (-1, await o, await e, true);
|
||||||
|
}
|
||||||
|
return (p.ExitCode, await o, await e, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace AgentWikiUi.Core;
|
||||||
|
|
||||||
|
enum InstallMethod { Native, Npm, Homebrew, WinGet }
|
||||||
|
|
||||||
|
/// <summary>Esito del controllo (sola lettura): versione installata (riga di `--version`), ultima pubblicata e se è più nuova. Available null = sconosciuto.</summary>
|
||||||
|
record UpdateCheck(string? InstalledText, string? Latest, bool? Available);
|
||||||
|
|
||||||
|
/// <summary>Esito di un aggiornamento: messaggio da mostrare e, se riuscito, le versioni prima e dopo.</summary>
|
||||||
|
record UpdateOutcome(bool Ok, string Message, string? Before, string? After);
|
||||||
|
|
||||||
|
// Aggiornamento di un CLI con il suo comando ufficiale (`<cli> update`). Vedi ADR-0004.
|
||||||
|
static class EngineUpdater
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan UpdateTimeout = TimeSpan.FromMinutes(10);
|
||||||
|
static readonly TimeSpan VersionTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
static readonly HttpClient SharedHttp = NewHttp();
|
||||||
|
|
||||||
|
static HttpClient NewHttp()
|
||||||
|
{
|
||||||
|
var http = new HttpClient { Timeout = CheckTimeout };
|
||||||
|
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(AppInfo.Name, "1.0")); // GitHub lo richiede
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Primo numero N.N.N del testo ("2.1.300 (Claude Code)", "codex-cli 0.162.1", "rust-v0.162.1").</summary>
|
||||||
|
public static Version? ParseVersion(string? text)
|
||||||
|
{
|
||||||
|
var m = text is null ? null : Regex.Match(text, @"\d+\.\d+\.\d+");
|
||||||
|
return m is { Success: true } && Version.TryParse(m.Value, out var v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>true se <paramref name="latest"/> è più nuova di <paramref name="installed"/>; null se una delle due non si legge.</summary>
|
||||||
|
public static bool? IsNewer(string? latest, string? installed) =>
|
||||||
|
ParseVersion(latest) is { } l && ParseVersion(installed) is { } i ? l > i : null;
|
||||||
|
|
||||||
|
/// <summary>Controlla se c'è una versione più nuova senza installare nulla. Mai eccezioni: rete o formato inattesi ⇒ sconosciuto.</summary>
|
||||||
|
public static async Task<UpdateCheck> CheckAsync(Engine engine, HttpClient? http = null)
|
||||||
|
{
|
||||||
|
var installed = await GetVersionAsync(engine);
|
||||||
|
string? latest = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(CheckTimeout);
|
||||||
|
latest = await engine.FetchLatestVersionAsync(http ?? SharedHttp, cts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException or JsonException or KeyNotFoundException or InvalidOperationException) { }
|
||||||
|
return new(installed, latest, IsNewer(latest, installed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prima riga di `--version` ripulita; null se l'eseguibile manca o fallisce.</summary>
|
||||||
|
public static async Task<string?> GetVersionAsync(Engine engine, TimeSpan? timeout = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await EngineRunner.RunToolAsync(engine, ["--version"], timeout ?? VersionTimeout);
|
||||||
|
if (r.TimedOut || r.Code != 0) return null;
|
||||||
|
return r.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (System.ComponentModel.Win32Exception) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<UpdateOutcome> UpdateAsync(Engine engine, TimeSpan? timeout = null)
|
||||||
|
{
|
||||||
|
var before = await GetVersionAsync(engine);
|
||||||
|
(int Code, string Out, string Err, bool TimedOut) r;
|
||||||
|
try { r = await EngineRunner.RunToolAsync(engine, ["update"], timeout ?? UpdateTimeout); }
|
||||||
|
catch (System.ComponentModel.Win32Exception)
|
||||||
|
{
|
||||||
|
return new(false, $"'{engine.Executable}' non trovato nel PATH.", before, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manual = ManualCommand(engine, DetectMethod(PathSearch.Find(engine.Executable)));
|
||||||
|
if (r.TimedOut) return new(false, $"Aggiornamento interrotto: più di {Elapsed(timeout ?? UpdateTimeout)}.\nComando manuale: {manual}", before, null);
|
||||||
|
if (r.Code != 0) return new(false, $"Aggiornamento non riuscito (codice {r.Code}).\n{Tail(r.Err, r.Out)}\nComando manuale: {manual}".Replace("\n\n", "\n"), before, null);
|
||||||
|
|
||||||
|
var after = await GetVersionAsync(engine);
|
||||||
|
if (before is not null && after is not null && before != after) return new(true, $"Aggiornato da {before} a {after}.", before, after);
|
||||||
|
return new(true, $"Già aggiornato ({after ?? before ?? "versione sconosciuta"}).", before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Elapsed(TimeSpan t) => t.TotalMinutes >= 1 ? $"{t.TotalMinutes:0} minuti" : $"{t.TotalSeconds:0} secondi";
|
||||||
|
|
||||||
|
// Ultime righe utili dell'output (stderr se c'è, altrimenti stdout).
|
||||||
|
static string Tail(string err, string output)
|
||||||
|
{
|
||||||
|
var text = string.IsNullOrWhiteSpace(err) ? output : err;
|
||||||
|
return string.Join('\n', text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).TakeLast(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Metodo di installazione dedotto dal percorso dell'eseguibile, anche dopo aver risolto i collegamenti simbolici.</summary>
|
||||||
|
public static InstallMethod DetectMethod(string? path)
|
||||||
|
{
|
||||||
|
if (path is null) return InstallMethod.Native;
|
||||||
|
foreach (var p in new[] { path, RealPath(path) })
|
||||||
|
{
|
||||||
|
if (Has(p, "node_modules") || Has(p, ".npm")) return InstallMethod.Npm;
|
||||||
|
if (Has(p, "Cellar") || Has(p, "homebrew") || Has(p, "linuxbrew")) return InstallMethod.Homebrew;
|
||||||
|
if (Has(p, "WinGet") || Has(p, "WindowsApps")) return InstallMethod.WinGet;
|
||||||
|
}
|
||||||
|
return InstallMethod.Native;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool Has(string path, string part) => path.Contains(part, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
static string RealPath(string path)
|
||||||
|
{
|
||||||
|
try { return new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? path; }
|
||||||
|
catch (IOException) { return path; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Comando da eseguire a mano quando l'aggiornamento automatico non basta (tabella dell'ADR-0004).</summary>
|
||||||
|
public static string ManualCommand(Engine engine, InstallMethod method) => (engine.Id, method) switch
|
||||||
|
{
|
||||||
|
("claude", InstallMethod.Npm) => "npm install -g @anthropic-ai/claude-code@latest",
|
||||||
|
("claude", InstallMethod.Homebrew) => "brew upgrade claude-code",
|
||||||
|
("claude", InstallMethod.WinGet) => "winget upgrade Anthropic.ClaudeCode",
|
||||||
|
("claude", _) => "claude update",
|
||||||
|
("codex", InstallMethod.Npm) => "npm install -g @openai/codex@latest",
|
||||||
|
("codex", InstallMethod.Homebrew) => "brew upgrade --cask codex",
|
||||||
|
("codex", _) => OperatingSystem.IsWindows()
|
||||||
|
? "powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\""
|
||||||
|
: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
|
||||||
|
_ => $"{engine.Executable} update",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,6 +57,24 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource Bg}" />
|
<Setter Property="Background" Value="{DynamicResource Bg}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.ghost.primary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Accent}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OnAccent}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.ghost.primary:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Accent}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OnAccent}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||||
|
<Setter Property="Opacity" Value="0.88" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.check">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource FgMuted}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.check.avail">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Accent}" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.mini">
|
<Style Selector="Button.mini">
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
<Setter Property="Padding" Value="8,2" />
|
<Setter Property="Padding" Value="8,2" />
|
||||||
@@ -93,6 +111,13 @@
|
|||||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="SelectableTextBlock.status">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource FgMuted}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="SelectableTextBlock.status.err">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Err}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.item">
|
<Style Selector="Border.item">
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="Margin" Value="8,1" />
|
<Setter Property="Margin" Value="8,1" />
|
||||||
|
|||||||
+94
-8
@@ -22,12 +22,41 @@ class ChatItem(string id, string title, DateTime updated, bool active, string en
|
|||||||
public bool IsActive { get; } = active;
|
public bool IsActive { get; } = active;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Voce dell'interruttore CLI: un motore non installato è mostrato ma non selezionabile.</summary>
|
/// <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)
|
class EngineChoice(Engine engine) : ObservableObject
|
||||||
{
|
{
|
||||||
|
string version = "";
|
||||||
|
string status = "";
|
||||||
|
bool statusIsError;
|
||||||
|
bool? available;
|
||||||
|
string? latest;
|
||||||
|
|
||||||
public Engine Engine { get; } = engine;
|
public Engine Engine { get; } = engine;
|
||||||
public bool Installed { get; } = engine.IsInstalled();
|
public bool Installed { get; } = engine.IsInstalled();
|
||||||
|
public string Name => Engine.Name;
|
||||||
public string Label => Installed ? Engine.Name : $"{Engine.Name} (non installato)";
|
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
|
class MainViewModel : ObservableObject
|
||||||
@@ -39,6 +68,10 @@ class MainViewModel : ObservableObject
|
|||||||
public ObservableCollection<MessageVm> Messages { get; } = [];
|
public ObservableCollection<MessageVm> Messages { get; } = [];
|
||||||
public ObservableCollection<ChatItem> Chats { get; } = [];
|
public ObservableCollection<ChatItem> Chats { get; } = [];
|
||||||
public EngineChoice[] EngineChoices { get; } = Engines.All.Select(e => new EngineChoice(e)).ToArray();
|
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 NewChatCommand => newChat;
|
||||||
public ICommand CopyCommand { get; }
|
public ICommand CopyCommand { get; }
|
||||||
@@ -46,6 +79,7 @@ class MainViewModel : ObservableObject
|
|||||||
public ICommand OpenCommand { get; }
|
public ICommand OpenCommand { get; }
|
||||||
public ICommand DeleteCommand { get; }
|
public ICommand DeleteCommand { get; }
|
||||||
public ICommand ToggleSidebarCommand { get; }
|
public ICommand ToggleSidebarCommand { get; }
|
||||||
|
public ICommand UpdateCommand { get; }
|
||||||
|
|
||||||
/// <summary>Conferma di eliminazione, fornita dalla finestra.</summary>
|
/// <summary>Conferma di eliminazione, fornita dalla finestra.</summary>
|
||||||
public Func<Task<bool>> ConfirmDelete { get; set; } = () => Task.FromResult(true);
|
public Func<Task<bool>> ConfirmDelete { get; set; } = () => Task.FromResult(true);
|
||||||
@@ -65,7 +99,7 @@ class MainViewModel : ObservableObject
|
|||||||
ModelOption[] modelChoices = [];
|
ModelOption[] modelChoices = [];
|
||||||
string[] effortChoices = [];
|
string[] effortChoices = [];
|
||||||
string input = "";
|
string input = "";
|
||||||
bool busy, sidebarOpen = true;
|
bool busy, updating, sidebarOpen = true;
|
||||||
ModelOption selectedModel = Default;
|
ModelOption selectedModel = Default;
|
||||||
string customModel = "";
|
string customModel = "";
|
||||||
string selectedEffort = DefaultEffort;
|
string selectedEffort = DefaultEffort;
|
||||||
@@ -77,14 +111,17 @@ class MainViewModel : ObservableObject
|
|||||||
ApplyEngine(selectedEngine);
|
ApplyEngine(selectedEngine);
|
||||||
send = new(_ => _ = SendAsync(), () => CanSend);
|
send = new(_ => _ = SendAsync(), () => CanSend);
|
||||||
SendCommand = send;
|
SendCommand = send;
|
||||||
newChat = new(_ => NewChat(), () => !busy);
|
newChat = new(_ => NewChat(), () => !busy && !updating);
|
||||||
CopyCommand = new RelayCommand<string>(t => { if (t is not null) _ = CopyText(t); });
|
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); });
|
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); });
|
DeleteCommand = new RelayCommand<ChatItem>(c => { if (c is not null) _ = DeleteAsync(c); });
|
||||||
ToggleSidebarCommand = new RelayCommand<object>(_ => SidebarOpen = !SidebarOpen);
|
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)); };
|
Messages.CollectionChanged += (_, _) => { Raise(nameof(HasMessages)); Raise(nameof(ShowEmpty)); };
|
||||||
ResetSession();
|
ResetSession();
|
||||||
|
|
||||||
|
_ = CheckUpdatesAsync();
|
||||||
|
|
||||||
// all'avvio riapre la chat più recente
|
// all'avvio riapre la chat più recente
|
||||||
if (ChatStore.List().FirstOrDefault() is { } last) OpenChat(last.Id); else RefreshChats();
|
if (ChatStore.List().FirstOrDefault() is { } last) OpenChat(last.Id); else RefreshChats();
|
||||||
}
|
}
|
||||||
@@ -93,7 +130,9 @@ class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
public bool HasMessages => Messages.Count > 0;
|
public bool HasMessages => Messages.Count > 0;
|
||||||
public bool ShowEmpty => Messages.Count == 0;
|
public bool ShowEmpty => Messages.Count == 0;
|
||||||
public bool CanSend => !busy && !string.IsNullOrWhiteSpace(input);
|
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 Author => Engine.Author;
|
||||||
public string InputPlaceholder => $"Scrivi un messaggio a {Engine.Author}…";
|
public string InputPlaceholder => $"Scrivi un messaggio a {Engine.Author}…";
|
||||||
public string BusyText => $"{Engine.Author} sta scrivendo…";
|
public string BusyText => $"{Engine.Author} sta scrivendo…";
|
||||||
@@ -107,12 +146,18 @@ class MainViewModel : ObservableObject
|
|||||||
public bool IsBusy
|
public bool IsBusy
|
||||||
{
|
{
|
||||||
get => busy;
|
get => busy;
|
||||||
private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); Raise(nameof(CanSwitchEngine)); send.Refresh(); newChat.Refresh(); } }
|
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); }
|
public bool SidebarOpen { get => sidebarOpen; set => Set(ref sidebarOpen, value); }
|
||||||
|
|
||||||
public bool CanSwitchEngine => !busy;
|
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>
|
/// <summary>Interruttore CLI: cambiare motore apre una nuova chat (il contesto non passa da un motore all'altro).</summary>
|
||||||
public EngineChoice SelectedEngine
|
public EngineChoice SelectedEngine
|
||||||
@@ -120,7 +165,7 @@ class MainViewModel : ObservableObject
|
|||||||
get => selectedEngine;
|
get => selectedEngine;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (value is null || value == selectedEngine || busy || !value.Installed) return;
|
if (value is null || value == selectedEngine || busy || updating || !value.Installed) return;
|
||||||
ApplyEngine(value);
|
ApplyEngine(value);
|
||||||
NewChat();
|
NewChat();
|
||||||
}
|
}
|
||||||
@@ -225,6 +270,47 @@ class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text, Author)); }
|
||||||
|
|
||||||
void Persist()
|
void Persist()
|
||||||
|
|||||||
+33
-6
@@ -122,8 +122,8 @@
|
|||||||
<Button Grid.Row="0" Classes="ghost" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="12" Padding="0" Width="36"
|
<Button Grid.Row="0" Classes="ghost" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="12" Padding="0" Width="36"
|
||||||
HorizontalContentAlignment="Center" AutomationProperties.Name="Impostazioni" ToolTip.Tip="Impostazioni">
|
HorizontalContentAlignment="Center" AutomationProperties.Name="Impostazioni" ToolTip.Tip="Impostazioni">
|
||||||
<Button.Flyout>
|
<Button.Flyout>
|
||||||
<Flyout Placement="BottomEdgeAlignedRight">
|
<Flyout Placement="BottomEdgeAlignedRight" Opening="OnSettingsOpening">
|
||||||
<StackPanel Width="300" Spacing="6" Margin="4">
|
<StackPanel Width="340" Spacing="6" Margin="4">
|
||||||
<TextBlock Text="Impostazioni" FontWeight="SemiBold" Margin="0,0,0,6" />
|
<TextBlock Text="Impostazioni" FontWeight="SemiBold" Margin="0,0,0,6" />
|
||||||
<TextBlock Text="CLI" FontSize="13" Foreground="{DynamicResource FgMuted}" />
|
<TextBlock Text="CLI" FontSize="13" Foreground="{DynamicResource FgMuted}" />
|
||||||
<ComboBox ItemsSource="{Binding EngineChoices}" SelectedItem="{Binding SelectedEngine}" IsEnabled="{Binding CanSwitchEngine}"
|
<ComboBox ItemsSource="{Binding EngineChoices}" SelectedItem="{Binding SelectedEngine}" IsEnabled="{Binding CanSwitchEngine}"
|
||||||
@@ -149,13 +149,40 @@
|
|||||||
<TextBlock Text="Effort" FontSize="13" Foreground="{DynamicResource FgMuted}" Margin="0,8,0,0" />
|
<TextBlock Text="Effort" FontSize="13" Foreground="{DynamicResource FgMuted}" Margin="0,8,0,0" />
|
||||||
<ComboBox ItemsSource="{Binding EffortChoices}" SelectedItem="{Binding SelectedEffort}" HorizontalAlignment="Stretch"
|
<ComboBox ItemsSource="{Binding EffortChoices}" SelectedItem="{Binding SelectedEffort}" HorizontalAlignment="Stretch"
|
||||||
AutomationProperties.Name="Effort di ragionamento" />
|
AutomationProperties.Name="Effort di ragionamento" />
|
||||||
|
<Border Height="1" Background="{DynamicResource Border}" Margin="0,12,0,4" />
|
||||||
|
<TextBlock Text="Aggiornamenti" FontSize="13" Foreground="{DynamicResource FgMuted}" />
|
||||||
|
<ItemsControl ItemsSource="{Binding InstalledEngines}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="gui:EngineChoice">
|
||||||
|
<StackPanel Spacing="4" Margin="0,8,0,0">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Name}" />
|
||||||
|
<TextBlock Text="{Binding Version}" FontSize="12" Foreground="{DynamicResource FgMuted}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="check" Classes.avail="{Binding UpdateAvailable}" Text="{Binding CheckText}" FontSize="12" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="ghost" Classes.primary="{Binding UpdateAvailable}" MinHeight="30" Padding="12,0" FontSize="13" Content="Aggiorna"
|
||||||
|
IsEnabled="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).CanUpdate}"
|
||||||
|
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).UpdateCommand}" CommandParameter="{Binding}"
|
||||||
|
AutomationProperties.Name="{Binding Name, StringFormat='Aggiorna {0}'}" />
|
||||||
|
</Grid>
|
||||||
|
<SelectableTextBlock Classes="status" Classes.err="{Binding StatusIsError}" Text="{Binding Status}" IsVisible="{Binding HasStatus}"
|
||||||
|
TextWrapping="Wrap" FontSize="12.5" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Flyout>
|
</Flyout>
|
||||||
</Button.Flyout>
|
</Button.Flyout>
|
||||||
<Viewbox Width="16" Height="16">
|
<Panel>
|
||||||
<Path Data="M4 7H11M17 7H20M4 17H7M13 17H20 M11 7A3 3 0 1 0 17 7A3 3 0 1 0 11 7Z M7 17A3 3 0 1 0 13 17A3 3 0 1 0 7 17Z"
|
<Viewbox Width="16" Height="16">
|
||||||
Stroke="{DynamicResource Fg}" StrokeThickness="2" StrokeLineCap="Round" />
|
<Path Data="M4 7H11M17 7H20M4 17H7M13 17H20 M11 7A3 3 0 1 0 17 7A3 3 0 1 0 11 7Z M7 17A3 3 0 1 0 13 17A3 3 0 1 0 7 17Z"
|
||||||
</Viewbox>
|
Stroke="{DynamicResource Fg}" StrokeThickness="2" StrokeLineCap="Round" />
|
||||||
|
</Viewbox>
|
||||||
|
<Ellipse Width="9" Height="9" Fill="{DynamicResource Accent}" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,-5,-6,0"
|
||||||
|
IsVisible="{Binding AnyUpdateAvailable}" />
|
||||||
|
</Panel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Composer -->
|
<!-- Composer -->
|
||||||
|
|||||||
@@ -45,4 +45,7 @@ partial class MainWindow : Window
|
|||||||
|
|
||||||
Opened += (_, _) => InputBox.Focus();
|
Opened += (_, _) => InputBox.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All'apertura del riquadro si ricontrolla se ci sono aggiornamenti (al massimo ogni 5 minuti).
|
||||||
|
void OnSettingsOpening(object? sender, EventArgs e) => _ = (DataContext as MainViewModel)?.CheckUpdatesAsync();
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user