268 lines
13 KiB
C#
268 lines
13 KiB
C#
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Net.Http.Headers;
|
|
|
|
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>Modello scelto all'avvio e a ogni cambio di CLI: il più piccolo, per risposte rapide ed economiche.</summary>
|
|
public abstract string DefaultModelId { get; }
|
|
public string DefaultEffort => "low";
|
|
/// <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);
|
|
/// <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;
|
|
|
|
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 string DefaultModelId => "claude-haiku-5-5";
|
|
|
|
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()));
|
|
|
|
// 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
|
|
{
|
|
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 string DefaultModelId => "gpt-5.6-luna";
|
|
|
|
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);
|
|
}
|
|
|
|
// 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.
|
|
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; }
|
|
}
|
|
}
|
|
|
|
sealed class AgyEngine : Engine
|
|
{
|
|
public override string Id => "agy";
|
|
public override string Name => "Antigravity";
|
|
public override string Author => "Antigravity";
|
|
public override string Executable => "agy";
|
|
public override bool OwnsSessionId => true;
|
|
|
|
public override ModelOption[] Models { get; } =
|
|
[
|
|
new("Gemini 3.0 Flash (Low)", "gemini-3.0-flash-low"),
|
|
new("Gemini 3.0 Flash (Medium)", "gemini-3.0-flash-medium"),
|
|
new("Gemini 3.0 Flash (High)", "gemini-3.0-flash-high"),
|
|
new("Claude Sonnet 4.6 (Thinking)", "claude-sonnet-4-6"),
|
|
new("Claude Opus 4.6 (Thinking)", "claude-opus-4-6-thinking"),
|
|
new("GPT-OSS 120B (Medium)", "gpt-oss-120b-medium"),
|
|
];
|
|
|
|
public override string[] Efforts { get; } = ["low", "medium", "high", "xhigh", "max"];
|
|
public override string DefaultModelId => "gemini-3.0-flash-low";
|
|
|
|
public override List<string> BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured)
|
|
{
|
|
var args = new List<string>();
|
|
if (structured) args.AddRange(["--output-format", "json"]);
|
|
if (resume && sessionId is not null) args.AddRange(["--conversation", sessionId]);
|
|
if (model != "") args.AddRange(["--model", model]);
|
|
if (effort != "") args.AddRange(["--effort", effort]);
|
|
args.AddRange(["--print-timeout", "120s"]);
|
|
args.AddRange(["-p", prompt]); // tutti i flag vanno passati PRIMA di -p
|
|
return args;
|
|
}
|
|
|
|
public override EngineReply Parse(int code, string stdout, string stderr)
|
|
{
|
|
string? conversationId = null;
|
|
string? response = null;
|
|
string? error = null;
|
|
|
|
var trimmed = stdout.Trim();
|
|
if (!string.IsNullOrEmpty(trimmed))
|
|
{
|
|
var start = trimmed.IndexOf('{');
|
|
var end = trimmed.LastIndexOf('}');
|
|
if (start >= 0 && end > start)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(trimmed.Substring(start, end - start + 1));
|
|
var root = doc.RootElement;
|
|
if (root.TryGetProperty("conversation_id", out var convProp))
|
|
conversationId = convProp.GetString();
|
|
if (root.TryGetProperty("response", out var respProp))
|
|
response = respProp.GetString();
|
|
if (root.TryGetProperty("status", out var statusProp) && statusProp.GetString() != "SUCCESS")
|
|
error = $"Stato {statusProp.GetString()}";
|
|
if (root.TryGetProperty("error", out var errProp))
|
|
error = errProp.GetString() ?? error;
|
|
}
|
|
catch (JsonException) { }
|
|
}
|
|
}
|
|
|
|
if (code != 0) return new("", conversationId, Failed(code, error ?? stderr.Trim()));
|
|
if (error is not null && string.IsNullOrWhiteSpace(response)) return new("", conversationId, error);
|
|
if (string.IsNullOrWhiteSpace(response)) return new("", conversationId, error ?? (string.IsNullOrWhiteSpace(stderr) ? "agy non ha restituito nessuna risposta." : stderr.Trim()));
|
|
return new(response.Trim(), conversationId, null);
|
|
}
|
|
|
|
public override async Task<string?> FetchLatestVersionAsync(HttpClient http, CancellationToken ct)
|
|
{
|
|
var text = await http.GetStringAsync("https://antigravity-cli-auto-updater-974169037036.us-central1.run.app", ct);
|
|
var m = Regex.Match(text, @"Stable Version:\s*v?(\d+\.\d+(?:\.\d+)?)");
|
|
return m.Success ? m.Groups[1].Value : text.Trim();
|
|
}
|
|
}
|
|
|
|
static class Engines
|
|
{
|
|
public static readonly Engine Claude = new ClaudeEngine();
|
|
public static readonly Engine Codex = new CodexEngine();
|
|
public static readonly Engine Agy = new AgyEngine();
|
|
public static readonly Engine[] All = [Claude, Codex, Agy];
|
|
|
|
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;
|
|
}
|
|
}
|