GUI Avalonia multipiattaforma in solo C# e rinomina in agent-wiki-ui
Sostituisce WinForms, WebView2 e ui.html con una GUI Avalonia (XAML, ViewModel, tema chiaro/scuro) e un renderer markdown che trasforma l'albero di Markdig in controlli. Un solo progetto net10.0 per Windows e Linux, con publish single-file compresso. L'avvio di claude è in un solo punto (Core/Claude.cs) condiviso da CLI e GUI. Il nome dell'app viene dall'AssemblyName; ChatStore migra la cartella dati LlmRun alla nuova. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c6a679e6a
commit
941d02110d
13 files changed
+966
-50
No files matched your search
@@ -0,0 +1,7 @@
|
||||
namespace AgentWikiUi.Core;
|
||||
|
||||
static class AppInfo
|
||||
{
|
||||
// Letto dall'AssemblyName del csproj: rinominare l'app = cambiare solo quello.
|
||||
public static readonly string Name = typeof(AppInfo).Assembly.GetName().Name!;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
|
||||
// Cronologia: un JSON per chat, il nome file è l'id di sessione di `claude`.
|
||||
static class ChatStore
|
||||
{
|
||||
// Nomi precedenti dell'app: le loro chat vengono spostate nella cartella attuale al primo avvio.
|
||||
static readonly string[] LegacyNames = ["LlmRun"];
|
||||
|
||||
static readonly string Dir = Prepare();
|
||||
|
||||
static string Prepare()
|
||||
{
|
||||
var data = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dir = Path.Combine(data, AppInfo.Name, "chats");
|
||||
if (Directory.Exists(dir)) return dir;
|
||||
foreach (var name in LegacyNames)
|
||||
{
|
||||
var old = Path.Combine(data, name, "chats");
|
||||
if (!Directory.Exists(old)) continue;
|
||||
try { Directory.CreateDirectory(Path.GetDirectoryName(dir)!); Directory.Move(old, dir); }
|
||||
catch (IOException) { } // se non si riesce a spostare, si riparte da una cartella vuota
|
||||
break;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
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)));
|
||||
}
|
||||
|
||||
public static Stored? Load(string id)
|
||||
{
|
||||
var file = PathFor(id);
|
||||
return file is not null && File.Exists(file) ? Read(file) : null;
|
||||
}
|
||||
|
||||
// Dalla più recente; i file corrotti sono ignorati.
|
||||
public static List<Stored> List() => Directory.Exists(Dir)
|
||||
? Directory.EnumerateFiles(Dir, "*.json").Select(Read).OfType<Stored>().OrderByDescending(c => c.Updated).ToList()
|
||||
: [];
|
||||
|
||||
public static void Delete(string id)
|
||||
{
|
||||
var file = PathFor(id);
|
||||
if (file is not null && File.Exists(file)) File.Delete(file);
|
||||
}
|
||||
|
||||
static Stored? Read(string file)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<Stored>(File.ReadAllText(file)); }
|
||||
catch (Exception e) when (e is IOException or JsonException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AgentWikiUi.Core;
|
||||
|
||||
record ModelOption(string Label, string Id);
|
||||
|
||||
static class Catalog
|
||||
{
|
||||
// Da aggiornare quando escono nuovi modelli.
|
||||
public static readonly ModelOption[] Models =
|
||||
[
|
||||
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 static readonly string[] Efforts = ["low", "medium", "high", "xhigh", "max"];
|
||||
}
|
||||
|
||||
record RunResult(int Code, string Out, string Err);
|
||||
|
||||
// Unico punto di avvio di `claude`, condiviso da CLI e GUI.
|
||||
static class Claude
|
||||
{
|
||||
// sessionId null = nessun contesto (CLI). Model/effort vuoti = flag omessi.
|
||||
public static ProcessStartInfo CreateStart(string prompt, bool redirectOutput, string? sessionId = null, bool resume = false, string model = "", string effort = "")
|
||||
{
|
||||
var psi = new ProcessStartInfo("claude")
|
||||
{
|
||||
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 di claude
|
||||
RedirectStandardOutput = redirectOutput,
|
||||
RedirectStandardError = redirectOutput,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("-p");
|
||||
psi.ArgumentList.Add(prompt);
|
||||
if (sessionId is not null)
|
||||
{
|
||||
psi.ArgumentList.Add(resume ? "--resume" : "--session-id");
|
||||
psi.ArgumentList.Add(sessionId);
|
||||
}
|
||||
foreach (var (flag, value) in new[] { ("--model", model), ("--effort", effort) })
|
||||
if (value != "") { psi.ArgumentList.Add(flag); psi.ArgumentList.Add(value); }
|
||||
return psi;
|
||||
}
|
||||
|
||||
// CLI: stdout/stderr ereditati (streaming), exit code propagato, 127 se `claude` manca.
|
||||
public static int RunInherited(string prompt)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(CreateStart(prompt, redirectOutput: false))!;
|
||||
p.StandardInput.Close();
|
||||
p.WaitForExit();
|
||||
return p.ExitCode;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Console.Error.WriteLine("'claude' non trovato nel PATH.");
|
||||
return 127;
|
||||
}
|
||||
}
|
||||
|
||||
// GUI: output catturato. Win32Exception se `claude` non è nel PATH.
|
||||
public static async Task<RunResult> RunCapturedAsync(string prompt, string sessionId, bool resume, string model, string effort)
|
||||
{
|
||||
using var p = Process.Start(CreateStart(prompt, redirectOutput: true, sessionId, resume, model, effort))!;
|
||||
p.StandardInput.Close();
|
||||
var o = p.StandardOutput.ReadToEndAsync();
|
||||
var e = p.StandardError.ReadToEndAsync();
|
||||
await p.WaitForExitAsync();
|
||||
return new(p.ExitCode, (await o).Trim(), (await e).Trim());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user