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);
|
||||
}
|
||||
}
|
||||
+92
-25
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Voce dell'interruttore CLI: un motore non installato è mostrato ma non selezionabile.</summary>
|
||||
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<MessageVm> Messages { get; } = [];
|
||||
public ObservableCollection<ChatItem> 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; }
|
||||
|
||||
/// <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;
|
||||
public Func<Task<bool>> ConfirmDelete { get; set; } = () => Task.FromResult(true);
|
||||
/// <summary>Chiesto alla finestra di rimettere il fuoco sul campo messaggio.</summary>
|
||||
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<Msg> 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<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);
|
||||
@@ -66,14 +83,20 @@ class MainViewModel : ObservableObject
|
||||
DeleteCommand = new RelayCommand<ChatItem>(c => { if (c is not null) _ = DeleteAsync(c); });
|
||||
ToggleSidebarCommand = new RelayCommand<object>(_ => 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;
|
||||
|
||||
/// <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 || !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<Msg> msgs)
|
||||
void Show(List<Msg> 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
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -93,7 +93,7 @@
|
||||
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<StackPanel IsVisible="{Binding IsAi}" Spacing="6">
|
||||
<TextBlock Text="Claude" FontWeight="SemiBold" FontSize="13.5" Foreground="{DynamicResource Accent}" />
|
||||
<TextBlock Text="{Binding Author}" FontWeight="SemiBold" FontSize="13.5" Foreground="{DynamicResource Accent}" />
|
||||
<gui:MarkdownView Source="{Binding Text}" />
|
||||
<Button Classes="ghost" HorizontalAlignment="Left" MinHeight="30" Padding="10,0" FontSize="13" Content="Copia"
|
||||
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).CopyCommand}" CommandParameter="{Binding Text}"
|
||||
@@ -108,7 +108,7 @@
|
||||
<!-- Attesa -->
|
||||
<StackPanel IsVisible="{Binding IsBusy}" Orientation="Horizontal" Spacing="12">
|
||||
<ProgressBar IsIndeterminate="True" Width="80" Height="4" Foreground="{DynamicResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Claude sta scrivendo…" Foreground="{DynamicResource FgMuted}" />
|
||||
<TextBlock Text="{Binding BusyText}" Foreground="{DynamicResource FgMuted}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
@@ -125,7 +125,19 @@
|
||||
<Flyout Placement="BottomEdgeAlignedRight">
|
||||
<StackPanel Width="300" Spacing="6" Margin="4">
|
||||
<TextBlock Text="Impostazioni" FontWeight="SemiBold" Margin="0,0,0,6" />
|
||||
<TextBlock Text="Modello" FontSize="13" Foreground="{DynamicResource FgMuted}" />
|
||||
<TextBlock Text="CLI" FontSize="13" Foreground="{DynamicResource FgMuted}" />
|
||||
<ComboBox ItemsSource="{Binding EngineChoices}" SelectedItem="{Binding SelectedEngine}" IsEnabled="{Binding CanSwitchEngine}"
|
||||
HorizontalAlignment="Stretch" AutomationProperties.Name="CLI (motore)">
|
||||
<ComboBox.Styles>
|
||||
<Style Selector="ComboBoxItem" x:DataType="gui:EngineChoice">
|
||||
<Setter Property="IsEnabled" Value="{Binding Installed}" />
|
||||
</Style>
|
||||
</ComboBox.Styles>
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="gui:EngineChoice"><TextBlock Text="{Binding Label}" /></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Text="Modello" FontSize="13" Foreground="{DynamicResource FgMuted}" Margin="0,8,0,0" />
|
||||
<ComboBox ItemsSource="{Binding ModelChoices}" SelectedItem="{Binding SelectedModel}" HorizontalAlignment="Stretch"
|
||||
AutomationProperties.Name="Modello">
|
||||
<ComboBox.ItemTemplate>
|
||||
@@ -151,7 +163,7 @@
|
||||
<Border Classes="composer" Background="{DynamicResource Surface}" BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="20" Padding="14,10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox x:Name="InputBox" Classes="bare" Text="{Binding Input, Mode=TwoWay}" AcceptsReturn="True" TextWrapping="Wrap" MaxHeight="200"
|
||||
PlaceholderText="Scrivi un messaggio a Claude…" BorderThickness="0" Background="Transparent"
|
||||
PlaceholderText="{Binding InputPlaceholder}" BorderThickness="0" Background="Transparent"
|
||||
VerticalContentAlignment="Center" AutomationProperties.Name="Messaggio" />
|
||||
<Button Grid.Column="1" Classes="send" VerticalAlignment="Bottom" Margin="8,0,0,0" Command="{Binding SendCommand}" AutomationProperties.Name="Invia messaggio">
|
||||
<Viewbox Width="18" Height="18"><Path Data="M12 19V5M5 12L12 5L19 12" Stroke="{Binding $parent[Button].Foreground}" StrokeThickness="2.2" StrokeLineCap="Round" StrokeJoin="Round" /></Viewbox>
|
||||
|
||||
+18
-2
@@ -2,7 +2,23 @@ using Avalonia;
|
||||
using AgentWikiUi.Core;
|
||||
using AgentWikiUi.Gui;
|
||||
|
||||
// Con argomenti: CLI (tutti gli argomenti uniti in un prompt). Senza: GUI.
|
||||
if (args.Length > 0) return Claude.RunInherited(string.Join(' ', args));
|
||||
// Con argomenti: CLI. Con `--engine <id>` per primo si sceglie il motore (default Claude Code);
|
||||
// il resto degli argomenti è unito in un solo prompt. Senza argomenti: GUI.
|
||||
if (args.Length > 0)
|
||||
{
|
||||
var engine = Engines.Claude;
|
||||
var words = args;
|
||||
if (args[0] == "--engine")
|
||||
{
|
||||
if (args.Length < 3 || Engines.Find(args[1]) is not { } chosen)
|
||||
{
|
||||
Console.Error.WriteLine($"Uso: <app> [--engine {string.Join('|', Engines.All.Select(e => e.Id))}] \"prompt\"");
|
||||
return 2;
|
||||
}
|
||||
engine = chosen;
|
||||
words = args[2..];
|
||||
}
|
||||
return EngineRunner.RunInherited(engine, string.Join(' ', words));
|
||||
}
|
||||
|
||||
return AppBuilder.Configure<App>().UsePlatformDetect().StartWithClassicDesktopLifetime(args);
|
||||
Reference in new issue
Block a user