From e60d64ed588dc19634c50ad7a0e4468f7b11c3e6 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 15 Oct 2026 11:40:47 +0200 Subject: [PATCH] Motori multipli: Codex e interruttore CLI nelle Impostazioni MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce l'astrazione Engine (Claude Code, Codex) con argomenti e parser propri e EngineRunner come unico punto di avvio, al posto di Claude.cs. Il JSON delle chat salva motore e id di sessione (i file vecchi restano chat di Claude Code). Cambiare CLI apre una nuova chat; modelli ed effort seguono il motore; un motore non installato non è selezionabile. La CLI accetta --engine . Codex gira in sola lettura e la sessione si legge da thread.started. Co-Authored-By: Claude Sonnet 5.5 --- Core/ChatStore.cs | 11 ++- Core/Engine.cs | 165 +++++++++++++++++++++++++++++++++++++++++++ Core/EngineRunner.cs | 50 +++++++++++++ Gui/MainViewModel.cs | 117 +++++++++++++++++++++++------- Gui/MainWindow.axaml | 20 ++++-- Program.cs | 20 +++++- 6 files changed, 349 insertions(+), 34 deletions(-) create mode 100644 Core/Engine.cs create mode 100644 Core/EngineRunner.cs diff --git a/Core/ChatStore.cs b/Core/ChatStore.cs index 2908c15..7864ae8 100644 --- a/Core/ChatStore.cs +++ b/Core/ChatStore.cs @@ -3,7 +3,12 @@ using System.Text.Json; namespace AgentWikiUi.Core; record Msg(string Role, string Text); // Role: user | ai | err -record Stored(string Id, string Title, DateTime Updated, bool Started, List Messages); +// Engine e SessionId mancano nei file scritti prima dei motori multipli: valgono Claude Code e SessionId = Id. +record Stored(string Id, string Title, DateTime Updated, bool Started, List Messages, string? Engine = null, string? SessionId = null) +{ + public string EngineId => Engine ?? "claude"; + public string? EffectiveSessionId => SessionId ?? (EngineId == "claude" ? Id : null); +} // Cronologia: un JSON per chat, il nome file è l'id di sessione di `claude`. static class ChatStore @@ -32,13 +37,13 @@ static class ChatStore // L'id può arrivare dalla UI e diventa un nome file: accettare solo Guid. static string? PathFor(string id) => Guid.TryParse(id, out var g) ? Path.Combine(Dir, g.ToString() + ".json") : null; - public static void Save(string id, bool started, List messages) + public static void Save(string id, string engine, string? sessionId, bool started, List messages) { if (messages.Count == 0) return; Directory.CreateDirectory(Dir); var first = messages.First(m => m.Role == "user").Text.ReplaceLineEndings(" ").Trim(); var title = first.Length > 50 ? first[..50] + "…" : first; - File.WriteAllText(PathFor(id)!, JsonSerializer.Serialize(new Stored(id, title, DateTime.Now, started, messages))); + File.WriteAllText(PathFor(id)!, JsonSerializer.Serialize(new Stored(id, title, DateTime.Now, started, messages, engine, sessionId))); } public static Stored? Load(string id) diff --git a/Core/Engine.cs b/Core/Engine.cs new file mode 100644 index 0000000..74faa13 --- /dev/null +++ b/Core/Engine.cs @@ -0,0 +1,165 @@ +using System.Text.Json; + +namespace AgentWikiUi.Core; + +record ModelOption(string Label, string Id); + +/// Esito di un messaggio: testo, id di sessione appreso dall'output (se il motore lo sceglie lui) o errore. +record EngineReply(string Text, string? SessionId, string? Error); + +/// Un motore CLI (Claude Code, Codex): come si avvia, cosa si può scegliere, come si legge l'output. Vedi ADR-0002. +abstract class Engine +{ + /// Identificatore stabile, salvato nei JSON delle chat e usato da --engine. + public abstract string Id { get; } + public abstract string Name { get; } + /// Etichetta sulle risposte ("Claude", "Codex"). + public abstract string Author { get; } + public abstract string Executable { get; } + public abstract ModelOption[] Models { get; } + public abstract string[] Efforts { get; } + /// True se l'id di sessione lo genera il motore e l'app lo legge dall'output. + public abstract bool OwnsSessionId { get; } + + /// true = GUI (output catturato e da interpretare); false = CLI (output ereditato). + public abstract List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured); + public abstract EngineReply Parse(int code, string stdout, string stderr); + + public bool IsInstalled() => PathSearch.Find(Executable) is not null; + + protected string Failed(int code, string detail) => $"{Executable} è uscito con codice {code}\n{detail}".Trim(); +} + +sealed class ClaudeEngine : Engine +{ + public override string Id => "claude"; + public override string Name => "Claude Code"; + public override string Author => "Claude"; + public override string Executable => "claude"; + public override bool OwnsSessionId => false; + + // Da aggiornare quando escono nuovi modelli. + public override ModelOption[] Models { get; } = + [ + new("Fable 5.1", "claude-fable-5-1"), + new("Opus 5.5", "claude-opus-5-5"), + new("Sonnet 5.5", "claude-sonnet-5-5"), + new("Haiku 5.5", "claude-haiku-5-5"), + ]; + + public override string[] Efforts { get; } = ["low", "medium", "high", "xhigh", "max"]; + + public override List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured) + { + var args = new List { "-p", prompt }; + if (sessionId is not null) args.AddRange([resume ? "--resume" : "--session-id", sessionId]); + if (model != "") args.AddRange(["--model", model]); + if (effort != "") args.AddRange(["--effort", effort]); + return args; + } + + public override EngineReply Parse(int code, string stdout, string stderr) => + code == 0 ? new(stdout.Trim(), null, null) : new("", null, Failed(code, stderr.Trim())); +} + +sealed class CodexEngine : Engine +{ + public override string Id => "codex"; + public override string Name => "Codex"; + public override string Author => "Codex"; + public override string Executable => "codex"; + public override bool OwnsSessionId => true; + + // Il catalogo di Codex è dinamico (~/.codex/models_cache.json): elenco fisso, da aggiornare. + public override ModelOption[] Models { get; } = + [ + new("GPT-5.6 Sol", "gpt-5.6-sol"), + new("GPT-5.6 Terra", "gpt-5.6-terra"), + new("GPT-5.6 Luna", "gpt-5.6-luna"), + ]; + + public override string[] Efforts { get; } = ["low", "medium", "high", "xhigh", "max"]; + + public override List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured) + { + var args = new List { "exec" }; + var resuming = resume && sessionId is not null; + if (resuming) args.Add("resume"); + if (structured) args.Add("--json"); + args.Add("--skip-git-repo-check"); + // `exec resume` non accetta -s: la sola lettura si impone con la configurazione + if (resuming) args.AddRange(["-c", "sandbox_mode=\"read-only\""]); + else args.AddRange(["-s", "read-only"]); + if (model != "") args.AddRange(["-m", model]); + if (effort != "") args.AddRange(["-c", $"model_reasoning_effort={effort}"]); + if (resuming) args.Add(sessionId!); + args.AddRange(["--", prompt]); // `--`: un prompt che inizia con "-" non è un'opzione + return args; + } + + // stdout è JSONL: thread.started (id), item.completed/agent_message (testo), turn.failed/error (errore). + public override EngineReply Parse(int code, string stdout, string stderr) + { + string? threadId = null, lastMessage = null, error = null; + foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + try + { + using var doc = JsonDocument.Parse(line); + var e = doc.RootElement; + switch (e.GetProperty("type").GetString()) + { + case "thread.started": threadId = e.GetProperty("thread_id").GetString(); break; + case "item.completed" when e.GetProperty("item").GetProperty("type").GetString() == "agent_message": + lastMessage = e.GetProperty("item").GetProperty("text").GetString(); break; + case "turn.failed": error = InnerMessage(e.GetProperty("error").GetProperty("message").GetString()); break; + case "error": error ??= InnerMessage(e.GetProperty("message").GetString()); break; + } + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { } // riga non JSON o evento sconosciuto + } + if (code != 0) return new("", threadId, Failed(code, error ?? stderr.Trim())); + if (string.IsNullOrWhiteSpace(lastMessage)) return new("", threadId, error ?? "codex non ha restituito nessuna risposta."); + return new(lastMessage.Trim(), threadId, null); + } + + // L'errore arriva come stringa che contiene un JSON {"error":{"message":…}}: si mostra il messaggio interno. + static string? InnerMessage(string? raw) + { + if (raw is null) return null; + try + { + using var doc = JsonDocument.Parse(raw); + return doc.RootElement.GetProperty("error").GetProperty("message").GetString() ?? raw; + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { return raw; } + } +} + +static class Engines +{ + public static readonly Engine Claude = new ClaudeEngine(); + public static readonly Engine Codex = new CodexEngine(); + public static readonly Engine[] All = [Claude, Codex]; + + public static Engine? Find(string? id) => All.FirstOrDefault(e => e.Id == id); +} + +static class PathSearch +{ + // Su Windows i motori si avviano direttamente: solo .exe (niente script .cmd, vedi ADR-0002). + public static string? Find(string name) + { + var file = OperatingSystem.IsWindows() ? name + ".exe" : name; + foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + try + { + var full = Path.Combine(dir.Trim('"'), file); + if (File.Exists(full)) return full; + } + catch (ArgumentException) { } // voce del PATH non valida + } + return null; + } +} diff --git a/Core/EngineRunner.cs b/Core/EngineRunner.cs new file mode 100644 index 0000000..da9bb8c --- /dev/null +++ b/Core/EngineRunner.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; + +namespace AgentWikiUi.Core; + +// Unico punto di avvio dei motori, condiviso da CLI e GUI. +static class EngineRunner +{ + public static ProcessStartInfo CreateStart(Engine engine, string prompt, bool captured, string? sessionId = null, bool resume = false, string model = "", string effort = "") + { + var psi = new ProcessStartInfo(engine.Executable) + { + WorkingDirectory = AppContext.BaseDirectory, // mai la cwd del chiamante né Assembly.Location (vuoto in single-file) + UseShellExecute = false, + RedirectStandardInput = true, // chiuso subito dal chiamante: evita l'attesa di stdin del motore + RedirectStandardOutput = captured, + RedirectStandardError = captured, + CreateNoWindow = true, + }; + foreach (var a in engine.BuildArgs(prompt, sessionId, resume, model, effort, structured: captured)) psi.ArgumentList.Add(a); + return psi; + } + + // CLI: stdout/stderr ereditati (streaming), exit code propagato, 127 se l'eseguibile manca. + public static int RunInherited(Engine engine, string prompt) + { + try + { + using var p = Process.Start(CreateStart(engine, prompt, captured: false))!; + p.StandardInput.Close(); + p.WaitForExit(); + return p.ExitCode; + } + catch (System.ComponentModel.Win32Exception) + { + Console.Error.WriteLine($"'{engine.Executable}' non trovato nel PATH."); + return 127; + } + } + + // GUI: output catturato e interpretato dal motore. Win32Exception se l'eseguibile non è nel PATH. + public static async Task RunCapturedAsync(Engine engine, string prompt, string? sessionId, bool resume, string model, string effort) + { + using var p = Process.Start(CreateStart(engine, prompt, captured: true, sessionId, resume, model, effort))!; + p.StandardInput.Close(); + var o = p.StandardOutput.ReadToEndAsync(); + var e = p.StandardError.ReadToEndAsync(); + await p.WaitForExitAsync(); + return engine.Parse(p.ExitCode, await o, await e); + } +} diff --git a/Gui/MainViewModel.cs b/Gui/MainViewModel.cs index 6117191..d938040 100644 --- a/Gui/MainViewModel.cs +++ b/Gui/MainViewModel.cs @@ -4,32 +4,41 @@ using AgentWikiUi.Core; namespace AgentWikiUi.Gui; -class MessageVm(string role, string text) +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) +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; } = updated.ToString("d MMM HH:mm"); + public string When { get; } = $"{engineName} · {updated:d MMM HH:mm}"; public bool IsActive { get; } = active; } +/// Voce dell'interruttore CLI: un motore non installato è mostrato ma non selezionabile. +class EngineChoice(Engine engine) +{ + public Engine Engine { get; } = engine; + public bool Installed { get; } = engine.IsInstalled(); + public string Label => Installed ? Engine.Name : $"{Engine.Name} (non installato)"; +} + class MainViewModel : ObservableObject { static readonly ModelOption Default = new("Predefinito", ""); static readonly ModelOption Custom = new("Altro ID…", "__custom"); + const string DefaultEffort = "Predefinito"; public ObservableCollection Messages { get; } = []; public ObservableCollection Chats { get; } = []; - public ModelOption[] ModelChoices { get; } = [Default, .. Catalog.Models, Custom]; - public string[] EffortChoices { get; } = ["Predefinito", .. Catalog.Efforts]; + public EngineChoice[] EngineChoices { get; } = Engines.All.Select(e => new EngineChoice(e)).ToArray(); public ICommand NewChatCommand => newChat; public ICommand CopyCommand { get; } @@ -39,25 +48,33 @@ class MainViewModel : ObservableObject public ICommand ToggleSidebarCommand { get; } /// Conferma di eliminazione, fornita dalla finestra. + public Func> ConfirmDelete { get; set; } = () => Task.FromResult(true); /// Copia negli appunti, fornita dalla finestra. public Func CopyText { get; set; } = _ => Task.CompletedTask; - public Func> ConfirmDelete { get; set; } = () => Task.FromResult(true); /// Chiesto alla finestra di rimettere il fuoco sul campo messaggio. public event Action? FocusInput; - string sessionId = Guid.NewGuid().ToString(); - bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume + // 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 history = []; + EngineChoice selectedEngine; + ModelOption[] modelChoices = []; + string[] effortChoices = []; string input = ""; bool busy, sidebarOpen = true; ModelOption selectedModel = Default; string customModel = ""; - string selectedEffort = "Predefinito"; + string selectedEffort = DefaultEffort; readonly RelayCommand send, newChat; public MainViewModel() { + selectedEngine = EngineChoices.FirstOrDefault(c => c.Installed) ?? EngineChoices[0]; + ApplyEngine(selectedEngine); send = new(_ => _ = SendAsync(), () => CanSend); SendCommand = send; newChat = new(_ => NewChat(), () => !busy); @@ -66,14 +83,20 @@ class MainViewModel : ObservableObject DeleteCommand = new RelayCommand(c => { if (c is not null) _ = DeleteAsync(c); }); ToggleSidebarCommand = new RelayCommand(_ => SidebarOpen = !SidebarOpen); Messages.CollectionChanged += (_, _) => { Raise(nameof(HasMessages)); Raise(nameof(ShowEmpty)); }; + ResetSession(); // 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 && !string.IsNullOrWhiteSpace(input); + public string Author => Engine.Author; + public string InputPlaceholder => $"Scrivi un messaggio a {Engine.Author}…"; + public string BusyText => $"{Engine.Author} sta scrivendo…"; public string Input { @@ -84,10 +107,28 @@ class MainViewModel : ObservableObject public bool IsBusy { get => busy; - private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); send.Refresh(); newChat.Refresh(); } } + private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); Raise(nameof(CanSwitchEngine)); send.Refresh(); newChat.Refresh(); } } } public bool SidebarOpen { get => sidebarOpen; set => Set(ref sidebarOpen, value); } + + public bool CanSwitchEngine => !busy; + + /// Interruttore CLI: cambiare motore apre una nuova chat (il contesto non passa da un motore all'altro). + public EngineChoice SelectedEngine + { + get => selectedEngine; + set + { + if (value is null || value == selectedEngine || busy || !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; @@ -97,37 +138,58 @@ class MainViewModel : ObservableObject 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 = [DefaultEffort, .. choice.Engine.Efforts]; + SelectedModel = Default; CustomModel = ""; SelectedEffort = 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 == sessionId)); + 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 msgs) + void Show(List msgs, string author) { Messages.Clear(); - foreach (var m in msgs) Messages.Add(new(m.Role, m.Text)); + foreach (var m in msgs) Messages.Add(new(m.Role, m.Text, author)); } void NewChat() { - sessionId = Guid.NewGuid().ToString(); started = false; history = []; - Show(history); RefreshChats(); FocusInput?.Invoke(); + ResetSession(); + Show(history, Author); RefreshChats(); FocusInput?.Invoke(); } void OpenChat(string id) { if (busy) return; if (ChatStore.Load(id) is not { } chat) { RefreshChats(); return; } - sessionId = chat.Id; started = chat.Started; history = chat.Messages; - Show(history); RefreshChats(); + 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 == sessionId) NewChat(); else RefreshChats(); + if (c.Id == chatId) NewChat(); else RefreshChats(); } async Task SendAsync() @@ -135,7 +197,7 @@ class MainViewModel : ObservableObject if (!CanSend) return; var text = input.Trim(); var model = IsCustomModel ? customModel.Trim() : selectedModel.Id; - var effort = selectedEffort == "Predefinito" ? "" : selectedEffort; + var effort = selectedEffort == DefaultEffort ? "" : selectedEffort; Input = ""; IsBusy = true; Add("user", text); @@ -143,11 +205,16 @@ class MainViewModel : ObservableObject RefreshChats(); try { - var r = await Claude.RunCapturedAsync(text, sessionId, started, model, effort); - if (r.Code == 0) { started = true; Add("ai", r.Out); } - else Add("err", $"claude è uscito con codice {r.Code}\n{r.Err}".Trim()); + 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", "'claude' non trovato nel PATH."); } + catch (System.ComponentModel.Win32Exception) { Add("err", $"'{Engine.Executable}' non trovato nel PATH."); } catch (Exception ex) { Add("err", ex.Message); } finally { @@ -158,11 +225,11 @@ class MainViewModel : ObservableObject } } - void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text)); } + void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text, Author)); } void Persist() { - try { ChatStore.Save(sessionId, started, history); } + try { ChatStore.Save(chatId, Engine.Id, sessionId, started, history); } catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce } } diff --git a/Gui/MainWindow.axaml b/Gui/MainWindow.axaml index 13c0ebc..f96dfc0 100644 --- a/Gui/MainWindow.axaml +++ b/Gui/MainWindow.axaml @@ -93,7 +93,7 @@ - +