using System.Diagnostics; using System.Net; using System.Reflection; using System.Text.Json; using System.Windows.Forms; using Markdig; using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.WinForms; // Host sottile: l'interfaccia vive in ui.html (embedded). Protocollo messaggi: docs/MVP.md. class Gui : Form { record Model(string Label, string Id); record Msg(string Role, string Text); // Role: user | ai | err record Stored(string Id, string Title, DateTime Updated, bool Started, List Messages); static readonly Model[] 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"), ]; static readonly MarkdownPipeline Md = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build(); 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, }; static readonly string ChatsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun", "chats"); string sessionId = Guid.NewGuid().ToString(); bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume bool busy; bool restored; // la chat più recente si riapre solo al primo avvio List messages = []; public Gui() { Text = "LlmRun"; 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); Load += async (_, _) => { var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun"); 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()); }; } 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() { 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 => $"")); return html.Replace("{{MODELS}}", "" + options); } void Post(object message) => view.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(message)); void OnMessage(string json) { using var doc = JsonDocument.Parse(json); var r = doc.RootElement; switch (r.GetProperty("type").GetString()) { 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()!); 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; messages.Add(new("user", text)); try { Save(); PostChats(); } catch (IOException) { } // il messaggio non va perso se l'app si chiude durante l'attesa try { var (code, stdout, stderr) = await RunClaude(text, model, effort); 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()); } 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(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().OrderByDescending(c => c.Updated).FirstOrDefault()?.Id : null; void PostChats() { var items = Directory.Exists(ChatsDir) ? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType() .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() }); } PostChats(); } async Task<(int Code, string Out, string Err)> RunClaude(string prompt, string model, string effort) { 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); foreach (var (flag, value) in new[] { ("--model", model), ("--effort", effort) }) if (value != "") { psi.ArgumentList.Add(flag); psi.ArgumentList.Add(value); } 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()); } }