Motori multipli: Codex e interruttore CLI nelle Impostazioni
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 <id>. Codex gira in sola lettura e la sessione si legge da thread.started. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c50bd63723
commit
e60d64ed58
6 files changed
+349
-34
No files matched your search
+8
-3
@@ -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<Msg> 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<Msg> 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<Msg> messages)
|
||||
public static void Save(string id, string engine, string? sessionId, bool started, List<Msg> 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)
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AgentWikiUi.Core;
|
||||
|
||||
record ModelOption(string Label, string Id);
|
||||
|
||||
/// <summary>Esito di un messaggio: testo, id di sessione appreso dall'output (se il motore lo sceglie lui) o errore.</summary>
|
||||
record EngineReply(string Text, string? SessionId, string? Error);
|
||||
|
||||
/// <summary>Un motore CLI (Claude Code, Codex): come si avvia, cosa si può scegliere, come si legge l'output. Vedi ADR-0002.</summary>
|
||||
abstract class Engine
|
||||
{
|
||||
/// <summary>Identificatore stabile, salvato nei JSON delle chat e usato da <c>--engine</c>.</summary>
|
||||
public abstract string Id { get; }
|
||||
public abstract string Name { get; }
|
||||
/// <summary>Etichetta sulle risposte ("Claude", "Codex").</summary>
|
||||
public abstract string Author { get; }
|
||||
public abstract string Executable { get; }
|
||||
public abstract ModelOption[] Models { get; }
|
||||
public abstract string[] Efforts { get; }
|
||||
/// <summary>True se l'id di sessione lo genera il motore e l'app lo legge dall'output.</summary>
|
||||
public abstract bool OwnsSessionId { get; }
|
||||
|
||||
/// <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 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<string> BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured)
|
||||
{
|
||||
var args = new List<string> { "-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<string> BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured)
|
||||
{
|
||||
var args = new List<string> { "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;
|
||||
}
|
||||
}
|
||||
@@ -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<EngineReply> 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user