Interfaccia riscritta in HTML/CSS e finestra 16:10
Tutta la UI passa in ui.html (WebView2): header, schermata iniziale con suggerimenti, composer a scheda con selettori modello/effort, messaggi aggiunti al DOM senza ricaricare la pagina, pulsanti Copia, attesa animata, tema chiaro/scuro di Windows. Gui.cs diventa un host sottile che dialoga con la pagina via WebMessage. La finestra ha proporzioni 16:10 calcolate sull'area utile dello schermo. Documentazione aggiornata. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba2c189750
commit
0d1784c0f4
6 files changed
+318
-107
No files matched your search
@@ -1,134 +1,106 @@
|
||||
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) { public override string ToString() => Label; }
|
||||
record Model(string Label, string Id);
|
||||
static readonly Model[] Models =
|
||||
[
|
||||
new("Fable 5.1 (claude-fable-5-1)", "claude-fable-5-1"),
|
||||
new("Opus 5.5 (claude-opus-5-5)", "claude-opus-5-5"),
|
||||
new("Sonnet 5.5 (claude-sonnet-5-5)", "claude-sonnet-5-5"),
|
||||
new("Haiku 5.5 (claude-haiku-5-5)", "claude-haiku-5-5"),
|
||||
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"),
|
||||
];
|
||||
const string Default = "predefinito";
|
||||
|
||||
static readonly MarkdownPipeline Md = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
|
||||
|
||||
const string Css = """
|
||||
:root { color-scheme: light dark; }
|
||||
body { font: 15px/1.6 'Segoe UI', sans-serif; margin: 0; padding: 16px 0 32px; background: Canvas; color: CanvasText; }
|
||||
.msg { max-width: 800px; margin: 0 auto 18px; padding: 0 16px; }
|
||||
.user { text-align: right; }
|
||||
.user div { display: inline-block; text-align: left; white-space: pre-wrap; background: color-mix(in srgb, CanvasText 10%, Canvas); padding: 10px 14px; border-radius: 18px; max-width: 85%; }
|
||||
.err div { color: #c0392b; white-space: pre-wrap; }
|
||||
.wait { opacity: .6; font-style: italic; }
|
||||
pre { background: color-mix(in srgb, CanvasText 8%, Canvas); padding: 12px; border-radius: 8px; overflow-x: auto; }
|
||||
code { font-family: Consolas, monospace; font-size: 13.5px; }
|
||||
table { border-collapse: collapse; } th, td { border: 1px solid color-mix(in srgb, CanvasText 25%, Canvas); padding: 4px 10px; }
|
||||
blockquote { margin-left: 0; padding-left: 12px; border-left: 3px solid color-mix(in srgb, CanvasText 30%, Canvas); }
|
||||
""";
|
||||
|
||||
// (ruolo, testo): "user" | "ai" | "err" | "wait"
|
||||
readonly List<(string Role, string Text)> chat = [];
|
||||
readonly WebView2 view = new() { Dock = DockStyle.Fill };
|
||||
readonly TextBox input = new() { Dock = DockStyle.Fill, Multiline = true, AcceptsReturn = true, ScrollBars = ScrollBars.Vertical, Font = new Font("Segoe UI", 11) };
|
||||
readonly Button send = new() { Text = "Invia", Dock = DockStyle.Fill };
|
||||
readonly Button reset = new() { Text = "Nuova chat", AutoSize = true };
|
||||
readonly ComboBox model = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 300 }; // editabile: ID completo
|
||||
readonly ComboBox effort = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 100 };
|
||||
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,
|
||||
};
|
||||
|
||||
string sessionId = Guid.NewGuid().ToString();
|
||||
bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume
|
||||
bool busy;
|
||||
|
||||
public Gui()
|
||||
{
|
||||
Text = "LlmRun";
|
||||
Width = 1000; Height = 700;
|
||||
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);
|
||||
|
||||
model.Items.Add(Default); model.Items.AddRange(Models); model.SelectedIndex = 0;
|
||||
effort.Items.AddRange([Default, "low", "medium", "high", "xhigh", "max"]); effort.SelectedIndex = 0;
|
||||
|
||||
var settings = new FlowLayoutPanel { Dock = DockStyle.Fill, WrapContents = false };
|
||||
settings.Controls.AddRange([
|
||||
new Label { Text = "Modello", AutoSize = true, Margin = new Padding(0, 8, 0, 0) }, model,
|
||||
new Label { Text = "Effort", AutoSize = true, Margin = new Padding(8, 8, 0, 0) }, effort, reset]);
|
||||
|
||||
var bottom = new TableLayoutPanel { Dock = DockStyle.Bottom, Height = 140, Padding = new Padding(8), ColumnCount = 2, RowCount = 2 };
|
||||
bottom.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
bottom.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 90));
|
||||
bottom.RowStyles.Add(new RowStyle(SizeType.Absolute, 34));
|
||||
bottom.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
bottom.Controls.Add(settings, 0, 0); bottom.SetColumnSpan(settings, 2);
|
||||
bottom.Controls.Add(input, 0, 1);
|
||||
bottom.Controls.Add(send, 1, 1);
|
||||
|
||||
Controls.Add(view); Controls.Add(bottom); // Fill per primo: l'ordine inverso dei Dock
|
||||
|
||||
send.Click += async (_, _) => await Send();
|
||||
reset.Click += (_, _) => { chat.Clear(); sessionId = Guid.NewGuid().ToString(); started = false; Render(); };
|
||||
input.KeyDown += async (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Enter && !e.Shift) { e.SuppressKeyPress = true; await Send(); }
|
||||
};
|
||||
Load += async (_, _) =>
|
||||
{
|
||||
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun");
|
||||
await view.EnsureCoreWebView2Async(await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(null, dir));
|
||||
view.CoreWebView2.NavigationStarting += (_, e) =>
|
||||
{
|
||||
if (e.Uri.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
e.Cancel = true;
|
||||
Process.Start(new ProcessStartInfo(e.Uri) { UseShellExecute = true });
|
||||
}
|
||||
};
|
||||
view.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
|
||||
Render();
|
||||
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());
|
||||
};
|
||||
}
|
||||
|
||||
void Render()
|
||||
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()
|
||||
{
|
||||
if (view.CoreWebView2 == null) return;
|
||||
var body = string.Concat(chat.Select(m => m.Role switch
|
||||
{
|
||||
"user" => $"<div class='msg user'><div>{WebUtility.HtmlEncode(m.Text)}</div></div>",
|
||||
"ai" => $"<div class='msg'>{Markdown.ToHtml(m.Text, Md)}</div>",
|
||||
"err" => $"<div class='msg err'><div>{WebUtility.HtmlEncode(m.Text)}</div></div>",
|
||||
_ => $"<div class='msg wait'>{WebUtility.HtmlEncode(m.Text)}</div>",
|
||||
}));
|
||||
view.NavigateToString($"<!doctype html><meta charset='utf-8'><style>{Css}</style><body>{body}<script>scrollTo(0,document.body.scrollHeight)</script></body>");
|
||||
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);
|
||||
}
|
||||
|
||||
async Task Send()
|
||||
void Post(object message) => view.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(message));
|
||||
|
||||
void OnMessage(string json)
|
||||
{
|
||||
var text = input.Text.Trim();
|
||||
if (text == "" || !send.Enabled) return;
|
||||
send.Enabled = false;
|
||||
input.Clear();
|
||||
chat.Add(("user", text));
|
||||
chat.Add(("wait", "Claude sta scrivendo…"));
|
||||
Render();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var r = doc.RootElement;
|
||||
switch (r.GetProperty("type").GetString())
|
||||
{
|
||||
case "reset":
|
||||
sessionId = Guid.NewGuid().ToString(); started = false;
|
||||
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;
|
||||
try
|
||||
{
|
||||
var (code, stdout, stderr) = await RunClaude(text);
|
||||
chat.RemoveAt(chat.Count - 1);
|
||||
if (code == 0) { started = true; chat.Add(("ai", stdout)); }
|
||||
else chat.Add(("err", $"claude è uscito con codice {code}\n{stderr}".Trim()));
|
||||
var (code, stdout, stderr) = await RunClaude(text, model, effort);
|
||||
if (code == 0) { started = true; Post(new { type = "ai", html = Markdown.ToHtml(stdout, Md), md = stdout }); }
|
||||
else Post(new { type = "err", text = $"claude è uscito con codice {code}\n{stderr}".Trim() });
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
chat.RemoveAt(chat.Count - 1);
|
||||
chat.Add(("err", "'claude' non trovato nel PATH."));
|
||||
}
|
||||
finally { send.Enabled = true; Render(); input.Focus(); }
|
||||
catch (System.ComponentModel.Win32Exception) { Post(new { type = "err", text = "'claude' non trovato nel PATH." }); }
|
||||
catch (Exception ex) { Post(new { type = "err", text = ex.Message }); }
|
||||
finally { busy = false; Post(new { type = "done" }); }
|
||||
}
|
||||
|
||||
async Task<(int Code, string Out, string Err)> RunClaude(string prompt)
|
||||
async Task<(int Code, string Out, string Err)> RunClaude(string prompt, string model, string effort)
|
||||
{
|
||||
var psi = new ProcessStartInfo("claude")
|
||||
{
|
||||
@@ -141,8 +113,8 @@ class Gui : Form
|
||||
psi.ArgumentList.Add(prompt);
|
||||
psi.ArgumentList.Add(started ? "--resume" : "--session-id");
|
||||
psi.ArgumentList.Add(sessionId);
|
||||
foreach (var (flag, value) in new[] { ("--model", model.SelectedItem is Model m ? m.Id : model.Text.Trim()), ("--effort", effort.Text) })
|
||||
if (value != "" && value != Default) { psi.ArgumentList.Add(flag); psi.ArgumentList.Add(value); }
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user