2026-10-08 12:23:32 +02:00
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.Net;
|
2026-10-08 13:29:53 +02:00
|
|
|
using System.Reflection;
|
|
|
|
|
using System.Text.Json;
|
2026-10-08 12:23:32 +02:00
|
|
|
using System.Windows.Forms;
|
|
|
|
|
using Markdig;
|
2026-10-08 13:29:53 +02:00
|
|
|
using Microsoft.Web.WebView2.Core;
|
2026-10-08 12:23:32 +02:00
|
|
|
using Microsoft.Web.WebView2.WinForms;
|
|
|
|
|
|
2026-10-08 13:29:53 +02:00
|
|
|
// Host sottile: l'interfaccia vive in ui.html (embedded). Protocollo messaggi: docs/MVP.md.
|
2026-10-08 12:23:32 +02:00
|
|
|
class Gui : Form
|
|
|
|
|
{
|
2026-10-08 13:29:53 +02:00
|
|
|
record Model(string Label, string Id);
|
2026-10-08 13:49:28 +02:00
|
|
|
record Msg(string Role, string Text); // Role: user | ai | err
|
|
|
|
|
record Stored(string Id, string Title, DateTime Updated, bool Started, List<Msg> Messages);
|
2026-10-08 12:23:32 +02:00
|
|
|
static readonly Model[] Models =
|
|
|
|
|
[
|
2026-10-08 13:29:53 +02:00
|
|
|
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"),
|
2026-10-08 12:23:32 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
static readonly MarkdownPipeline Md = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
|
|
|
|
|
|
2026-10-08 13:29:53 +02:00
|
|
|
readonly WebView2 view = new()
|
|
|
|
|
{
|
|
|
|
|
Dock = DockStyle.Fill,
|
|
|
|
|
// evita il lampo bianco all'avvio in tema scuro (stesso --bg di ui.html)
|
|
|
|
|
DefaultBackgroundColor = Application.IsDarkModeEnabled ? Color.FromArgb(0x0F, 0x17, 0x2A) : Color.White,
|
|
|
|
|
};
|
2026-10-08 12:23:32 +02:00
|
|
|
|
2026-10-08 13:49:28 +02:00
|
|
|
static readonly string ChatsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun", "chats");
|
|
|
|
|
|
2026-10-08 12:23:32 +02:00
|
|
|
string sessionId = Guid.NewGuid().ToString();
|
|
|
|
|
bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume
|
2026-10-08 13:29:53 +02:00
|
|
|
bool busy;
|
2026-10-08 13:49:28 +02:00
|
|
|
bool restored; // la chat più recente si riapre solo al primo avvio
|
|
|
|
|
List<Msg> messages = [];
|
2026-10-08 12:23:32 +02:00
|
|
|
|
|
|
|
|
public Gui()
|
|
|
|
|
{
|
|
|
|
|
Text = "LlmRun";
|
2026-10-08 13:29:53 +02:00
|
|
|
var area = Screen.PrimaryScreen!.WorkingArea;
|
|
|
|
|
StartPosition = FormStartPosition.CenterScreen;
|
|
|
|
|
// proporzioni 16:10 orizzontali: 75% della larghezza utile (max 1440), altezza max 90%
|
|
|
|
|
var w = Math.Min(1440, area.Width * 3 / 4);
|
|
|
|
|
var h = Math.Min(w * 10 / 16, area.Height * 9 / 10);
|
|
|
|
|
Size = new Size(h * 16 / 10, h);
|
|
|
|
|
MinimumSize = new Size(900, 600);
|
|
|
|
|
Controls.Add(view);
|
2026-10-08 12:23:32 +02:00
|
|
|
|
|
|
|
|
Load += async (_, _) =>
|
|
|
|
|
{
|
|
|
|
|
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun");
|
2026-10-08 13:29:53 +02:00
|
|
|
await view.EnsureCoreWebView2Async(await CoreWebView2Environment.CreateAsync(null, dir));
|
|
|
|
|
var web = view.CoreWebView2;
|
|
|
|
|
web.Settings.AreDevToolsEnabled = Debugger.IsAttached;
|
|
|
|
|
web.NavigationStarting += (_, e) => { if (IsWeb(e.Uri)) { e.Cancel = true; Open(e.Uri); } };
|
|
|
|
|
web.NewWindowRequested += (_, e) => { e.Handled = true; if (IsWeb(e.Uri)) Open(e.Uri); };
|
|
|
|
|
web.WebMessageReceived += (_, e) => OnMessage(e.WebMessageAsJson);
|
|
|
|
|
view.NavigateToString(LoadPage());
|
2026-10-08 12:23:32 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 13:29:53 +02:00
|
|
|
static bool IsWeb(string uri) => uri.StartsWith("http", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
static void Open(string uri) => Process.Start(new ProcessStartInfo(uri) { UseShellExecute = true });
|
|
|
|
|
|
|
|
|
|
static string LoadPage()
|
2026-10-08 12:23:32 +02:00
|
|
|
{
|
2026-10-08 13:29:53 +02:00
|
|
|
var asm = Assembly.GetExecutingAssembly();
|
|
|
|
|
using var s = asm.GetManifestResourceStream(asm.GetManifestResourceNames().First(n => n.EndsWith("ui.html")))!;
|
|
|
|
|
var html = new StreamReader(s).ReadToEnd();
|
|
|
|
|
var options = string.Concat(Models.Select(m => $"<option value=\"{m.Id}\">{WebUtility.HtmlEncode(m.Label)}</option>"));
|
|
|
|
|
return html.Replace("{{MODELS}}", "<option value=\"\">Predefinito</option>" + options);
|
2026-10-08 12:23:32 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-08 13:29:53 +02:00
|
|
|
void Post(object message) => view.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(message));
|
|
|
|
|
|
|
|
|
|
void OnMessage(string json)
|
2026-10-08 12:23:32 +02:00
|
|
|
{
|
2026-10-08 13:29:53 +02:00
|
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
|
var r = doc.RootElement;
|
|
|
|
|
switch (r.GetProperty("type").GetString())
|
|
|
|
|
{
|
2026-10-08 13:49:28 +02:00
|
|
|
case "reset" when !busy:
|
|
|
|
|
NewSession(); PostChats();
|
|
|
|
|
break;
|
|
|
|
|
case "list":
|
|
|
|
|
if (!restored) { restored = true; if (LatestId() is { } last) { OpenChat(last); break; } }
|
|
|
|
|
PostChats();
|
|
|
|
|
break;
|
|
|
|
|
case "open" when !busy:
|
|
|
|
|
OpenChat(r.GetProperty("id").GetString()!);
|
|
|
|
|
break;
|
|
|
|
|
case "delete" when !busy:
|
|
|
|
|
Delete(r.GetProperty("id").GetString()!);
|
2026-10-08 13:29:53 +02:00
|
|
|
break;
|
|
|
|
|
case "send" when !busy:
|
|
|
|
|
_ = HandleSend(r.GetProperty("text").GetString()!, r.GetProperty("model").GetString()!, r.GetProperty("effort").GetString()!);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task HandleSend(string text, string model, string effort)
|
|
|
|
|
{
|
|
|
|
|
busy = true;
|
2026-10-08 13:49:28 +02:00
|
|
|
messages.Add(new("user", text));
|
|
|
|
|
try { Save(); PostChats(); } catch (IOException) { } // il messaggio non va perso se l'app si chiude durante l'attesa
|
2026-10-08 12:23:32 +02:00
|
|
|
try
|
|
|
|
|
{
|
2026-10-08 13:29:53 +02:00
|
|
|
var (code, stdout, stderr) = await RunClaude(text, model, effort);
|
2026-10-08 13:49:28 +02:00
|
|
|
if (code == 0) { started = true; messages.Add(new("ai", stdout)); Post(new { type = "ai", html = Markdown.ToHtml(stdout, Md), md = stdout }); }
|
|
|
|
|
else Fail($"claude è uscito con codice {code}\n{stderr}".Trim());
|
2026-10-08 12:23:32 +02:00
|
|
|
}
|
2026-10-08 13:49:28 +02:00
|
|
|
catch (System.ComponentModel.Win32Exception) { Fail("'claude' non trovato nel PATH."); }
|
|
|
|
|
catch (Exception ex) { Fail(ex.Message); }
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
busy = false;
|
|
|
|
|
try { Save(); } catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce
|
|
|
|
|
PostChats();
|
|
|
|
|
Post(new { type = "done" });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Fail(string text) { messages.Add(new("err", text)); Post(new { type = "err", text }); }
|
|
|
|
|
|
|
|
|
|
void NewSession() { sessionId = Guid.NewGuid().ToString(); started = false; messages = []; }
|
|
|
|
|
|
|
|
|
|
// L'id arriva dalla pagina e diventa un nome file: accettare solo Guid.
|
|
|
|
|
static string? PathFor(string id) => Guid.TryParse(id, out var g) ? Path.Combine(ChatsDir, g.ToString() + ".json") : null;
|
|
|
|
|
|
|
|
|
|
void Save()
|
|
|
|
|
{
|
|
|
|
|
if (messages.Count == 0) return;
|
|
|
|
|
Directory.CreateDirectory(ChatsDir);
|
|
|
|
|
var first = messages.First(m => m.Role == "user").Text.ReplaceLineEndings(" ").Trim();
|
|
|
|
|
var title = first.Length > 50 ? first[..50] + "…" : first;
|
|
|
|
|
File.WriteAllText(PathFor(sessionId)!, JsonSerializer.Serialize(new Stored(sessionId, title, DateTime.Now, started, messages)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static Stored? Read(string file)
|
|
|
|
|
{
|
|
|
|
|
try { return JsonSerializer.Deserialize<Stored>(File.ReadAllText(file)); }
|
|
|
|
|
catch (Exception e) when (e is IOException or JsonException) { return null; } // file corrotto: ignorato
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string? LatestId() => Directory.Exists(ChatsDir)
|
|
|
|
|
? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType<Stored>().OrderByDescending(c => c.Updated).FirstOrDefault()?.Id
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
void PostChats()
|
|
|
|
|
{
|
|
|
|
|
var items = Directory.Exists(ChatsDir)
|
|
|
|
|
? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType<Stored>()
|
|
|
|
|
.OrderByDescending(c => c.Updated).Select(c => new { id = c.Id, title = c.Title, updated = c.Updated }).ToArray()
|
|
|
|
|
: [];
|
|
|
|
|
Post(new { type = "chats", current = sessionId, items });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void OpenChat(string id)
|
|
|
|
|
{
|
|
|
|
|
var file = PathFor(id);
|
|
|
|
|
var chat = file is not null && File.Exists(file) ? Read(file) : null;
|
|
|
|
|
if (chat is null) { PostChats(); return; }
|
|
|
|
|
sessionId = chat.Id; started = chat.Started; messages = chat.Messages;
|
|
|
|
|
Post(new
|
|
|
|
|
{
|
|
|
|
|
type = "load",
|
|
|
|
|
messages = messages.Select(m => new { role = m.Role, text = m.Text, html = m.Role == "ai" ? Markdown.ToHtml(m.Text, Md) : null }).ToArray(),
|
|
|
|
|
});
|
|
|
|
|
PostChats();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Delete(string id)
|
|
|
|
|
{
|
|
|
|
|
var file = PathFor(id);
|
|
|
|
|
if (file is not null && File.Exists(file)) File.Delete(file);
|
|
|
|
|
if (id == sessionId)
|
|
|
|
|
{
|
|
|
|
|
NewSession();
|
|
|
|
|
Post(new { type = "load", messages = Array.Empty<object>() });
|
|
|
|
|
}
|
|
|
|
|
PostChats();
|
2026-10-08 12:23:32 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-08 13:29:53 +02:00
|
|
|
async Task<(int Code, string Out, string Err)> RunClaude(string prompt, string model, string effort)
|
2026-10-08 12:23:32 +02:00
|
|
|
{
|
|
|
|
|
var psi = new ProcessStartInfo("claude")
|
|
|
|
|
{
|
|
|
|
|
WorkingDirectory = AppContext.BaseDirectory,
|
|
|
|
|
UseShellExecute = false,
|
|
|
|
|
RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true,
|
|
|
|
|
CreateNoWindow = true,
|
|
|
|
|
};
|
|
|
|
|
psi.ArgumentList.Add("-p");
|
|
|
|
|
psi.ArgumentList.Add(prompt);
|
|
|
|
|
psi.ArgumentList.Add(started ? "--resume" : "--session-id");
|
|
|
|
|
psi.ArgumentList.Add(sessionId);
|
2026-10-08 13:29:53 +02:00
|
|
|
foreach (var (flag, value) in new[] { ("--model", model), ("--effort", effort) })
|
|
|
|
|
if (value != "") { psi.ArgumentList.Add(flag); psi.ArgumentList.Add(value); }
|
2026-10-08 12:23:32 +02:00
|
|
|
|
|
|
|
|
using var p = Process.Start(psi)!;
|
|
|
|
|
p.StandardInput.Close(); // evita l'attesa di stdin di claude
|
|
|
|
|
var o = p.StandardOutput.ReadToEndAsync();
|
|
|
|
|
var e = p.StandardError.ReadToEndAsync();
|
|
|
|
|
await p.WaitForExitAsync();
|
|
|
|
|
return (p.ExitCode, (await o).Trim(), (await e).Trim());
|
|
|
|
|
}
|
|
|
|
|
}
|