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());
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="AgentWikiUi.Gui.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- Token di design: vedi docs/MVP.md → "Design (UI/UX)". Il tema segue il sistema. -->
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<SolidColorBrush x:Key="Bg">#FFFFFF</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Surface">#FFFFFF</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Fg">#0F172A</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Muted">#F1F5F9</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="FgMuted">#475569</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Border">#CBD5E1</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Accent">#15803D</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="OnAccent">#FFFFFF</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="AccentSoft">#DCFCE7</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Err">#B91C1C</SolidColorBrush>
|
||||
</ResourceDictionary>
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="Bg">#0F172A</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Surface">#1E293B</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Fg">#F8FAFC</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Muted">#1E293B</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="FgMuted">#94A3B8</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Border">#334155</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Accent">#22C55E</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="OnAccent">#0F172A</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="AccentSoft">#14301F</SolidColorBrush>
|
||||
<SolidColorBrush x:Key="Err">#F87171</SolidColorBrush>
|
||||
</ResourceDictionary>
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="Background" Value="{DynamicResource Bg}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Fg}" />
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI, Inter, Ubuntu, Cantarell, Noto Sans, DejaVu Sans, sans-serif" />
|
||||
<Setter Property="FontSize" Value="15.5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="MinHeight" Value="36" />
|
||||
<Setter Property="Padding" Value="14,0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Bg}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="Button.mini">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Padding" Value="8,2" />
|
||||
<Setter Property="MinHeight" Value="24" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Background" Value="{DynamicResource Surface}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Button.chip">
|
||||
<Setter Property="Background" Value="{DynamicResource Surface}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="16,12" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
</Style>
|
||||
<Style Selector="Button.chip:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Muted}" />
|
||||
</Style>
|
||||
<Style Selector="Button.send">
|
||||
<Setter Property="Width" Value="36" />
|
||||
<Setter Property="Height" Value="36" />
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource OnAccent}" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.send:disabled /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Border}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}" />
|
||||
</Style>
|
||||
<Style Selector="Button.send:pointerover:not(:disabled) /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="Opacity" Value="0.88" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox.bare /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Border.composer:focus-within">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.item">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Margin" Value="8,1" />
|
||||
<Setter Property="BorderThickness" Value="3,0,0,0" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Border.item.active">
|
||||
<Setter Property="Background" Value="{DynamicResource Bg}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="Border.item:pointerover">
|
||||
<Setter Property="Background" Value="{DynamicResource Bg}" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -0,0 +1,17 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AgentWikiUi.Gui;
|
||||
|
||||
class App : Application
|
||||
{
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.MainWindow = new MainWindow();
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
|
||||
namespace AgentWikiUi.Gui;
|
||||
|
||||
static class ConfirmDialog
|
||||
{
|
||||
public static Task<bool> Ask(Window owner, string question)
|
||||
{
|
||||
var yes = new Button { Content = "Elimina", MinWidth = 90 };
|
||||
var no = new Button { Content = "Annulla", MinWidth = 90 };
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = question, Width = 340, SizeToContent = SizeToContent.Height, CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new(20), Spacing = 18,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = question, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { no, yes } },
|
||||
},
|
||||
},
|
||||
};
|
||||
yes.Click += (_, _) => dlg.Close(true);
|
||||
no.Click += (_, _) => dlg.Close(false);
|
||||
return dlg.ShowDialog<bool>(owner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
using AgentWikiUi.Core;
|
||||
|
||||
namespace AgentWikiUi.Gui;
|
||||
|
||||
class MessageVm(string role, string text)
|
||||
{
|
||||
public string Role { get; } = role;
|
||||
public string Text { get; } = text;
|
||||
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)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
public string Title { get; } = title;
|
||||
public string When { get; } = updated.ToString("d MMM HH:mm");
|
||||
public bool IsActive { get; } = active;
|
||||
}
|
||||
|
||||
record Suggestion(string Title, string Subtitle, string Prompt);
|
||||
|
||||
class MainViewModel : ObservableObject
|
||||
{
|
||||
static readonly ModelOption Default = new("Predefinito", "");
|
||||
static readonly ModelOption Custom = new("Altro ID…", "__custom");
|
||||
|
||||
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 Suggestion[] Suggestions { get; } =
|
||||
[
|
||||
new("Spiega il progetto", "Panoramica e struttura", "Spiega cosa fa questo progetto e come è organizzato."),
|
||||
new("Scrivi uno script", "PowerShell, Python, C#…", "Scrivi uno script PowerShell che "),
|
||||
new("Rivedi del codice", "Bug, leggibilità, prestazioni", "Rivedi questo codice e suggerisci miglioramenti:\n\n"),
|
||||
new("Riassumi un testo", "Punti chiave in breve", "Riassumi in modo chiaro questo testo:\n\n"),
|
||||
];
|
||||
|
||||
public ICommand NewChatCommand => newChat;
|
||||
public ICommand CopyCommand { get; }
|
||||
public ICommand SendCommand { get; }
|
||||
public ICommand OpenCommand { get; }
|
||||
public ICommand DeleteCommand { get; }
|
||||
public ICommand SuggestCommand { get; }
|
||||
public ICommand ToggleSidebarCommand { get; }
|
||||
|
||||
/// <summary>Conferma di eliminazione, fornita dalla finestra.</summary>
|
||||
/// <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
|
||||
List<Msg> history = [];
|
||||
|
||||
string input = "";
|
||||
bool busy, sidebarOpen = true;
|
||||
ModelOption selectedModel = Default;
|
||||
string customModel = "";
|
||||
string selectedEffort = "Predefinito";
|
||||
readonly RelayCommand<object> send, newChat;
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
send = new(_ => _ = SendAsync(), () => CanSend);
|
||||
SendCommand = send;
|
||||
newChat = new(_ => NewChat(), () => !busy);
|
||||
CopyCommand = new RelayCommand<string>(t => { if (t is not null) _ = CopyText(t); });
|
||||
OpenCommand = new RelayCommand<ChatItem>(c => { if (c is not null) OpenChat(c.Id); });
|
||||
DeleteCommand = new RelayCommand<ChatItem>(c => { if (c is not null) _ = DeleteAsync(c); });
|
||||
SuggestCommand = new RelayCommand<Suggestion>(s => { if (s is not null) { Input = s.Prompt; FocusInput?.Invoke(); } });
|
||||
ToggleSidebarCommand = new RelayCommand<object>(_ => SidebarOpen = !SidebarOpen);
|
||||
Messages.CollectionChanged += (_, _) => { Raise(nameof(HasMessages)); Raise(nameof(ShowEmpty)); };
|
||||
|
||||
// all'avvio riapre la chat più recente
|
||||
if (ChatStore.List().FirstOrDefault() is { } last) OpenChat(last.Id); else RefreshChats();
|
||||
}
|
||||
|
||||
public bool HasMessages => Messages.Count > 0;
|
||||
public bool ShowEmpty => Messages.Count == 0;
|
||||
public bool CanSend => !busy && !string.IsNullOrWhiteSpace(input);
|
||||
|
||||
public string Input
|
||||
{
|
||||
get => input;
|
||||
set { if (Set(ref input, value)) { Raise(nameof(CanSend)); send.Refresh(); } }
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => busy;
|
||||
private set { if (Set(ref busy, value)) { Raise(nameof(CanSend)); send.Refresh(); newChat.Refresh(); } }
|
||||
}
|
||||
|
||||
public bool SidebarOpen { get => sidebarOpen; set => Set(ref sidebarOpen, value); }
|
||||
public ModelOption SelectedModel
|
||||
{
|
||||
get => selectedModel;
|
||||
set { if (Set(ref selectedModel, value)) Raise(nameof(IsCustomModel)); }
|
||||
}
|
||||
public bool IsCustomModel => selectedModel == Custom;
|
||||
public string CustomModel { get => customModel; set => Set(ref customModel, value); }
|
||||
public string SelectedEffort { get => selectedEffort; set => Set(ref selectedEffort, value); }
|
||||
|
||||
void RefreshChats()
|
||||
{
|
||||
Chats.Clear();
|
||||
foreach (var c in ChatStore.List()) Chats.Add(new(c.Id, c.Title, c.Updated, c.Id == sessionId));
|
||||
}
|
||||
|
||||
void Show(List<Msg> msgs)
|
||||
{
|
||||
Messages.Clear();
|
||||
foreach (var m in msgs) Messages.Add(new(m.Role, m.Text));
|
||||
}
|
||||
|
||||
void NewChat()
|
||||
{
|
||||
sessionId = Guid.NewGuid().ToString(); started = false; history = [];
|
||||
Show(history); 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();
|
||||
}
|
||||
|
||||
async Task DeleteAsync(ChatItem c)
|
||||
{
|
||||
if (busy || !await ConfirmDelete()) return;
|
||||
ChatStore.Delete(c.Id);
|
||||
if (c.Id == sessionId) NewChat(); else RefreshChats();
|
||||
}
|
||||
|
||||
async Task SendAsync()
|
||||
{
|
||||
if (!CanSend) return;
|
||||
var text = input.Trim();
|
||||
var model = IsCustomModel ? customModel.Trim() : selectedModel.Id;
|
||||
var effort = selectedEffort == "Predefinito" ? "" : selectedEffort;
|
||||
|
||||
Input = ""; IsBusy = true;
|
||||
Add("user", text);
|
||||
Persist(); // il messaggio non va perso se l'app si chiude durante l'attesa
|
||||
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());
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception) { Add("err", "'claude' non trovato nel PATH."); }
|
||||
catch (Exception ex) { Add("err", ex.Message); }
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
Persist();
|
||||
RefreshChats();
|
||||
FocusInput?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
void Add(string role, string text) { history.Add(new(role, text)); Messages.Add(new(role, text)); }
|
||||
|
||||
void Persist()
|
||||
{
|
||||
try { ChatStore.Save(sessionId, started, history); }
|
||||
catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:gui="using:AgentWikiUi.Gui"
|
||||
xmlns:core="using:AgentWikiUi.Core"
|
||||
x:Class="AgentWikiUi.Gui.MainWindow"
|
||||
x:DataType="gui:MainViewModel"
|
||||
Title="{x:Static core:AppInfo.Name}"
|
||||
MinWidth="900" MinHeight="600">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- Barra laterale -->
|
||||
<Border Grid.Column="0" Width="272" IsVisible="{Binding SidebarOpen}"
|
||||
Background="{DynamicResource Muted}" BorderBrush="{DynamicResource Border}" BorderThickness="0,0,1,0">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="14,16,14,10" Spacing="14">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="4,0">
|
||||
<Border Width="28" Height="28" CornerRadius="8" Background="{DynamicResource Accent}">
|
||||
<Viewbox Width="16" Height="16">
|
||||
<Path Data="M12 2L14.4 8.6L21 11L14.4 13.4L12 20L9.6 13.4L3 11L9.6 8.6Z" Fill="{DynamicResource OnAccent}" />
|
||||
</Viewbox>
|
||||
</Border>
|
||||
<TextBlock Text="{x:Static core:AppInfo.Name}" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<Button Classes="ghost" Command="{Binding NewChatCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Background="{DynamicResource Bg}" AutomationProperties.Name="Nuova chat">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Viewbox Width="16" Height="16"><Path Data="M12 5V19M5 12H19" Stroke="{DynamicResource Fg}" StrokeThickness="2" StrokeLineCap="Round" /></Viewbox>
|
||||
<TextBlock Text="Nuova chat" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="CHAT" FontSize="11.5" FontWeight="SemiBold" Foreground="{DynamicResource FgMuted}" Margin="22,8,14,4" />
|
||||
<TextBlock DockPanel.Dock="Top" Text="Nessuna chat salvata" Foreground="{DynamicResource FgMuted}" FontSize="13.5" Margin="22,6"
|
||||
IsVisible="{Binding !Chats.Count}" />
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Chats}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="gui:ChatItem">
|
||||
<Border Classes="item" Classes.active="{Binding IsActive}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button Grid.ColumnSpan="2" Background="Transparent" BorderThickness="0" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left" Padding="12,8,40,8"
|
||||
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).OpenCommand}" CommandParameter="{Binding}"
|
||||
AutomationProperties.Name="{Binding Title, StringFormat='Apri chat: {0}'}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontSize="14" />
|
||||
<TextBlock Text="{Binding When}" FontSize="12" Foreground="{DynamicResource FgMuted}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="1" Background="Transparent" BorderThickness="0" Width="32" Height="32" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center" Padding="0" HorizontalContentAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).DeleteCommand}" CommandParameter="{Binding}"
|
||||
AutomationProperties.Name="Elimina chat">
|
||||
<Viewbox Width="12" Height="12"><Path Data="M6 6L18 18M18 6L6 18" Stroke="{DynamicResource FgMuted}" StrokeThickness="2.4" StrokeLineCap="Round" /></Viewbox>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Area principale -->
|
||||
<Grid Grid.Column="1" RowDefinitions="*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Row="0" x:Name="Scroll">
|
||||
<StackPanel MaxWidth="780" Margin="24,56,24,16" Spacing="20">
|
||||
|
||||
<!-- Schermata iniziale -->
|
||||
<StackPanel IsVisible="{Binding ShowEmpty}" Spacing="10" Margin="0,40,0,0" HorizontalAlignment="Center">
|
||||
<Border Width="44" Height="44" CornerRadius="12" Background="{DynamicResource Accent}" HorizontalAlignment="Center">
|
||||
<Viewbox Width="24" Height="24">
|
||||
<Path Data="M12 2L14.4 8.6L21 11L14.4 13.4L12 20L9.6 13.4L3 11L9.6 8.6Z" Fill="{DynamicResource OnAccent}" />
|
||||
</Viewbox>
|
||||
</Border>
|
||||
<TextBlock Text="Come posso aiutarti?" FontSize="28" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="Scrivi un messaggio oppure parti da uno di questi spunti." Foreground="{DynamicResource FgMuted}" HorizontalAlignment="Center" />
|
||||
<ItemsControl ItemsSource="{Binding Suggestions}" Margin="0,16,0,0" Width="640">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="2" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="gui:Suggestion">
|
||||
<Button Classes="chip" Margin="5" Command="{Binding $parent[ItemsControl].((gui:MainViewModel)DataContext).SuggestCommand}" CommandParameter="{Binding}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Subtitle}" FontSize="12.5" Foreground="{DynamicResource FgMuted}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Conversazione -->
|
||||
<ItemsControl ItemsSource="{Binding Messages}" AutomationProperties.Name="Conversazione">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Spacing="22" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="gui:MessageVm">
|
||||
<Panel>
|
||||
<Border IsVisible="{Binding IsUser}" HorizontalAlignment="Right" MaxWidth="620" CornerRadius="18,18,4,18"
|
||||
Background="{DynamicResource AccentSoft}" Padding="16,10">
|
||||
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<StackPanel IsVisible="{Binding IsAi}" Spacing="6">
|
||||
<TextBlock Text="Claude" 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}"
|
||||
AutomationProperties.Name="Copia la risposta" />
|
||||
</StackPanel>
|
||||
<SelectableTextBlock IsVisible="{Binding IsErr}" Text="{Binding Text}" TextWrapping="Wrap" Foreground="{DynamicResource Err}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 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}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Button Grid.Row="0" Classes="ghost" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="12" Padding="0" Width="36"
|
||||
HorizontalContentAlignment="Center" Command="{Binding ToggleSidebarCommand}" AutomationProperties.Name="Mostra o nascondi le chat">
|
||||
<Viewbox Width="16" Height="16"><Path Data="M4 6H20M4 12H20M4 18H20" Stroke="{DynamicResource Fg}" StrokeThickness="2" StrokeLineCap="Round" /></Viewbox>
|
||||
</Button>
|
||||
|
||||
<!-- Composer -->
|
||||
<StackPanel Grid.Row="1" MaxWidth="780" Margin="24,0,24,12">
|
||||
<Border Classes="composer" Background="{DynamicResource Surface}" BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="20" Padding="14,10">
|
||||
<StackPanel Spacing="8">
|
||||
<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"
|
||||
AutomationProperties.Name="Messaggio" />
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
||||
<TextBlock Text="Modello" FontSize="13" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding ModelChoices}" SelectedItem="{Binding SelectedModel}" MinWidth="150"
|
||||
AutomationProperties.Name="Modello">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="core:ModelOption"><TextBlock Text="{Binding Label}" /></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBox Grid.Column="2" Text="{Binding CustomModel}" IsVisible="{Binding IsCustomModel}" PlaceholderText="es. claude-opus-5-5" Width="190"
|
||||
Margin="8,0,0,0" AutomationProperties.Name="ID modello personalizzato" />
|
||||
<TextBlock Grid.Column="3" Text="Effort" FontSize="13" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="16,0,8,0" />
|
||||
<ComboBox Grid.Column="3" ItemsSource="{Binding EffortChoices}" SelectedItem="{Binding SelectedEffort}" MinWidth="130"
|
||||
Margin="64,0,0,0" AutomationProperties.Name="Effort di ragionamento" />
|
||||
<Button Grid.Column="5" Classes="send" 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>
|
||||
</Button>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Text="Invio per inviare · Maiusc+Invio per andare a capo" FontSize="12" Foreground="{DynamicResource FgMuted}"
|
||||
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,48 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using AgentWikiUi.Core;
|
||||
|
||||
namespace AgentWikiUi.Gui;
|
||||
|
||||
partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
var vm = new MainViewModel();
|
||||
DataContext = vm;
|
||||
|
||||
// proporzioni 16:10 orizzontali: 75% della larghezza utile (max 1440), altezza max 90%
|
||||
var screen = Screens.Primary;
|
||||
if (screen is not null)
|
||||
{
|
||||
var area = screen.WorkingArea;
|
||||
double w = Math.Min(1440, area.Width / screen.Scaling * 3 / 4);
|
||||
var h = Math.Min(w * 10 / 16, area.Height / screen.Scaling * 9 / 10);
|
||||
Width = h * 16 / 10; Height = h;
|
||||
}
|
||||
else { Width = 1280; Height = 800; }
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
|
||||
vm.CopyText = async text => { if (Clipboard is { } cb) await cb.SetTextAsync(text); };
|
||||
vm.ConfirmDelete = () => ConfirmDialog.Ask(this, "Eliminare questa chat?");
|
||||
vm.FocusInput += () => Dispatcher.UIThread.Post(() => InputBox.Focus());
|
||||
vm.Messages.CollectionChanged += (_, _) => Dispatcher.UIThread.Post(() => Scroll.ScrollToEnd(), DispatcherPriority.Background);
|
||||
|
||||
// Invio invia, Maiusc+Invio va a capo (tunnel: prima che il TextBox inserisca l'a capo)
|
||||
InputBox.AddHandler(KeyDownEvent, (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
e.Handled = true;
|
||||
if (vm.SendCommand.CanExecute(null)) vm.SendCommand.Execute(null);
|
||||
}
|
||||
}, RoutingStrategies.Tunnel);
|
||||
|
||||
Opened += (_, _) => InputBox.Focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Markdig;
|
||||
using Markdig.Extensions.Tables;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace AgentWikiUi.Gui;
|
||||
|
||||
/// <summary>Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui.</summary>
|
||||
class MarkdownView : Decorator
|
||||
{
|
||||
public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(nameof(Source));
|
||||
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
|
||||
static readonly FontFamily Mono = new("Cascadia Code, Consolas, DejaVu Sans Mono, Liberation Mono, monospace");
|
||||
|
||||
public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); }
|
||||
|
||||
public MarkdownView()
|
||||
{
|
||||
// i colori si risolvono in fase di rendering: al cambio di tema si ricostruisce
|
||||
ActualThemeVariantChanged += (_, _) => Rebuild();
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == SourceProperty) Rebuild();
|
||||
}
|
||||
|
||||
void Rebuild() => Child = string.IsNullOrEmpty(Source) ? null : RenderBlocks(Markdig.Markdown.Parse(Source, Pipeline), 10);
|
||||
|
||||
IBrush Brush(string key) => this.TryFindResource(key, ActualThemeVariant, out var v) && v is IBrush b ? b : Brushes.Gray;
|
||||
|
||||
Control RenderBlocks(IEnumerable<Block> blocks, double spacing)
|
||||
{
|
||||
var panel = new StackPanel { Spacing = spacing };
|
||||
foreach (var b in blocks) panel.Children.Add(RenderBlock(b));
|
||||
return panel;
|
||||
}
|
||||
|
||||
Control RenderBlock(Block block) => block switch
|
||||
{
|
||||
HeadingBlock h => Text(h.Inline, bold: true, size: h.Level switch { 1 => 24, 2 => 20, 3 => 18, 4 => 16, _ => 15.5 }),
|
||||
ParagraphBlock p => Text(p.Inline),
|
||||
FencedCodeBlock or CodeBlock => Code(string.Join('\n', ((LeafBlock)block).Lines.Lines.Take(((LeafBlock)block).Lines.Count).Select(l => l.ToString()))),
|
||||
QuoteBlock q => new Border { BorderBrush = Brush("Border"), BorderThickness = new(3, 0, 0, 0), Padding = new(12, 0, 0, 0), Child = RenderBlocks(q, 8) },
|
||||
ListBlock l => List(l),
|
||||
Table t => TableOf(t),
|
||||
ThematicBreakBlock => new Border { Height = 1, Background = Brush("Border"), Margin = new(0, 6) },
|
||||
LeafBlock leaf => Text(leaf.Inline),
|
||||
ContainerBlock c => RenderBlocks(c, 8),
|
||||
_ => new Panel(),
|
||||
};
|
||||
|
||||
Control List(ListBlock list)
|
||||
{
|
||||
var panel = new StackPanel { Spacing = 4 };
|
||||
var n = int.TryParse(list.OrderedStart, out var start) ? start : 1;
|
||||
foreach (var item in list.OfType<ListItemBlock>())
|
||||
{
|
||||
var row = new Grid { ColumnDefinitions = new("Auto,*") };
|
||||
var marker = new TextBlock { Text = list.IsOrdered ? $"{n++}." : "•", Foreground = Brush("FgMuted"), Margin = new(0, 0, 8, 0), MinWidth = 18, TextAlignment = TextAlignment.Right };
|
||||
var body = RenderBlocks(item, 4);
|
||||
Grid.SetColumn(body, 1);
|
||||
row.Children.Add(marker);
|
||||
row.Children.Add(body);
|
||||
panel.Children.Add(row);
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
Control Code(string code)
|
||||
{
|
||||
var text = new SelectableTextBlock { Text = code, FontFamily = Mono, FontSize = 13.5, Padding = new(14, 12, 64, 12) };
|
||||
var copy = new Button { Content = "Copia", Classes = { "mini" }, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Top, Margin = new(0, 6, 6, 0) };
|
||||
copy.Click += async (_, _) =>
|
||||
{
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is { } cb) await cb.SetTextAsync(code);
|
||||
copy.Content = "Copiato";
|
||||
await Task.Delay(1400);
|
||||
copy.Content = "Copia";
|
||||
};
|
||||
return new Border
|
||||
{
|
||||
Background = Brush("Muted"), BorderBrush = Brush("Border"), BorderThickness = new(1), CornerRadius = new(10),
|
||||
Child = new Grid { Children = { new ScrollViewer { HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, Content = text }, copy } },
|
||||
};
|
||||
}
|
||||
|
||||
Control TableOf(Table table)
|
||||
{
|
||||
var grid = new Grid();
|
||||
var rows = table.OfType<TableRow>().ToList();
|
||||
var cols = rows.Max(r => r.Count);
|
||||
for (var c = 0; c < cols; c++) grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
|
||||
for (var r = 0; r < rows.Count; r++)
|
||||
{
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
var cells = rows[r].OfType<TableCell>().ToList();
|
||||
for (var c = 0; c < cells.Count; c++)
|
||||
{
|
||||
var cell = RenderBlocks(cells[c], 4);
|
||||
if (cell is StackPanel { Children: [SelectableTextBlock tb] }) // intestazione in grassetto
|
||||
if (rows[r].IsHeader) tb.FontWeight = FontWeight.SemiBold;
|
||||
var align = c < table.ColumnDefinitions.Count ? table.ColumnDefinitions[c].Alignment : null;
|
||||
cell.HorizontalAlignment = align switch { TableColumnAlign.Center => HorizontalAlignment.Center, TableColumnAlign.Right => HorizontalAlignment.Right, _ => HorizontalAlignment.Left };
|
||||
var box = new Border
|
||||
{
|
||||
BorderBrush = Brush("Border"), BorderThickness = new(0.5), Padding = new(10, 6),
|
||||
Background = rows[r].IsHeader ? Brush("Muted") : null, Child = cell,
|
||||
};
|
||||
Grid.SetRow(box, r); Grid.SetColumn(box, c);
|
||||
grid.Children.Add(box);
|
||||
}
|
||||
}
|
||||
return new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), CornerRadius = new(6), ClipToBounds = true, Child = grid };
|
||||
}
|
||||
|
||||
// Testo con inline; i link sono Run colorati e il clic si risolve con l'hit-test sul testo.
|
||||
SelectableTextBlock Text(ContainerInline? inline, bool bold = false, double size = 0)
|
||||
{
|
||||
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
||||
if (size > 0) tb.FontSize = size;
|
||||
if (bold) tb.FontWeight = FontWeight.SemiBold;
|
||||
var links = new List<(int Start, int End, string Url)>();
|
||||
var pos = 0;
|
||||
AddInlines(tb.Inlines!, inline, new(), links, ref pos);
|
||||
if (links.Count > 0) WireLinks(tb, links);
|
||||
return tb;
|
||||
}
|
||||
|
||||
record struct Style(bool Bold, bool Italic, bool Strike, bool Link);
|
||||
|
||||
void AddInlines(InlineCollection target, ContainerInline? container, Style st, List<(int, int, string)> links, ref int pos)
|
||||
{
|
||||
if (container is null) return;
|
||||
foreach (var node in container)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case LiteralInline lit: Add(target, lit.Content.ToString(), st, ref pos); break;
|
||||
case LineBreakInline br: Add(target, br.IsHard ? "\n" : " ", st, ref pos); break;
|
||||
case CodeInline code:
|
||||
target.Add(new Run(code.Content) { FontFamily = Mono, FontSize = 13.5, Background = Brush("Muted") });
|
||||
pos += code.Content.Length;
|
||||
break;
|
||||
case EmphasisInline em:
|
||||
var inner = em.DelimiterChar == '~' ? st with { Strike = true }
|
||||
: em.DelimiterCount >= 2 ? st with { Bold = true } : st with { Italic = true };
|
||||
AddInlines(target, em, inner, links, ref pos);
|
||||
break;
|
||||
case LinkInline { IsImage: true } img:
|
||||
Add(target, "[" + string.Concat(img.OfType<LiteralInline>().Select(l => l.Content.ToString())) + "]", st, ref pos);
|
||||
break;
|
||||
case LinkInline link:
|
||||
var start = pos;
|
||||
AddInlines(target, link, st with { Link = true }, links, ref pos);
|
||||
if (link.Url is { } url) links.Add((start, pos, url));
|
||||
break;
|
||||
case AutolinkInline auto:
|
||||
var s = pos;
|
||||
Add(target, auto.Url, st with { Link = true }, ref pos);
|
||||
links.Add((s, pos, auto.Url));
|
||||
break;
|
||||
case ContainerInline c: AddInlines(target, c, st, links, ref pos); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Add(InlineCollection target, string text, Style st, ref int pos)
|
||||
{
|
||||
var run = new Run(text);
|
||||
if (st.Bold) run.FontWeight = FontWeight.Bold;
|
||||
if (st.Italic) run.FontStyle = FontStyle.Italic;
|
||||
if (st.Strike) run.TextDecorations = TextDecorations.Strikethrough;
|
||||
if (st.Link) { run.Foreground = Brush("Accent"); run.TextDecorations = TextDecorations.Underline; }
|
||||
target.Add(run);
|
||||
pos += text.Length;
|
||||
}
|
||||
|
||||
void WireLinks(SelectableTextBlock tb, List<(int Start, int End, string Url)> links)
|
||||
{
|
||||
string? UrlAt(Point p)
|
||||
{
|
||||
var i = tb.TextLayout.HitTestPoint(p).TextPosition;
|
||||
foreach (var (s, e, url) in links) if (i >= s && i < e) return url;
|
||||
return null;
|
||||
}
|
||||
tb.PointerMoved += (_, e) => tb.Cursor = UrlAt(e.GetPosition(tb)) is null ? null : new Cursor(StandardCursorType.Hand);
|
||||
tb.PointerReleased += async (_, e) =>
|
||||
{
|
||||
if (tb.SelectionStart != tb.SelectionEnd) return; // era una selezione, non un clic
|
||||
if (UrlAt(e.GetPosition(tb)) is { } url && Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
|
||||
if (TopLevel.GetTopLevel(this)?.Launcher is { } l) await l.LaunchUriAsync(uri);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace AgentWikiUi.Gui;
|
||||
|
||||
class ObservableObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
Raise(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void Raise([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new(name));
|
||||
}
|
||||
|
||||
class RelayCommand<T>(Action<T?> run, Func<bool>? canRun = null) : ICommand
|
||||
{
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
public bool CanExecute(object? parameter) => canRun?.Invoke() ?? true;
|
||||
public void Execute(object? parameter) => run(parameter is T t ? t : default);
|
||||
public void Refresh() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
+6
-42
@@ -1,44 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Windows.Forms;
|
||||
using Avalonia;
|
||||
using AgentWikiUi.Core;
|
||||
using AgentWikiUi.Gui;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
// WebView2 richiede un thread STA, che i top-level statements non danno
|
||||
var ui = new Thread(() =>
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.SetColorMode(SystemColorMode.System);
|
||||
Application.Run(new Gui());
|
||||
});
|
||||
ui.SetApartmentState(ApartmentState.STA);
|
||||
ui.Start();
|
||||
ui.Join();
|
||||
return 0;
|
||||
}
|
||||
// Con argomenti: CLI (tutti gli argomenti uniti in un prompt). Senza: GUI.
|
||||
if (args.Length > 0) return Claude.RunInherited(string.Join(' ', args));
|
||||
|
||||
var psi = NewClaude(string.Join(' ', args));
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(psi)!; // stdout/stderr ereditati: streaming in tempo reale
|
||||
p.StandardInput.Close();
|
||||
p.WaitForExit();
|
||||
return p.ExitCode;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Console.Error.WriteLine("'claude' non trovato nel PATH.");
|
||||
return 127;
|
||||
}
|
||||
|
||||
static ProcessStartInfo NewClaude(string prompt)
|
||||
{
|
||||
var psi = new ProcessStartInfo("claude")
|
||||
{
|
||||
WorkingDirectory = AppContext.BaseDirectory,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true, // evita l'attesa di stdin di claude
|
||||
};
|
||||
psi.ArgumentList.Add("-p");
|
||||
psi.ArgumentList.Add(prompt);
|
||||
return psi;
|
||||
}
|
||||
return AppBuilder.Configure<App>().UsePlatformDetect().StartWithClassicDesktopLifetime(args);
|
||||
+12
-8
@@ -2,20 +2,24 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Unica sorgente del nome dell'app (vedi docs/README.md → "Nome del progetto") -->
|
||||
<AssemblyName>agent-wiki-ui</AssemblyName>
|
||||
<RootNamespace>AgentWikiUi</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);WFO5001</NoWarn> <!-- SetColorMode è sperimentale -->
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<!-- publish single-file: le librerie native (Skia, HarfBuzz) vengono estratte al primo avvio -->
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.4" />
|
||||
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4258.31" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="ui.html" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user