diff --git a/AGENT.md b/AGENT.md index 9695509..eaed81e 100644 --- a/AGENT.md +++ b/AGENT.md @@ -17,7 +17,7 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md - Build: `dotnet build` dalla radice (soluzione `agent-wiki-ui.slnx`: app in `src/agent-wiki-ui/`, test in `tests/`; `net10.0`, Avalonia) - GUI: `dotnet run --project src/agent-wiki-ui`; CLI: `dotnet run --project src/agent-wiki-ui -- "prompt"` - Publish single-file (uno per sistema): `scripts/publish.sh` (Linux/WSL/macOS) o `scripts/publish.ps1` (Windows PowerShell): creano `dist/win-x64/` e `dist/linux-x64/` (`dist/` è ignorata da git); i due script vanno tenuti allineati, oppure a mano `dotnet publish src/agent-wiki-ui -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64` -- Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale). Per ora coprono solo le immagini del renderer (MD-14, MD-19…MD-27); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. +- Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale). Per ora coprono solo le immagini del renderer (MD-14, MD-19…MD-30); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. ## Architettura @@ -31,7 +31,7 @@ Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa de - [Core/AppInfo.cs](src/agent-wiki-ui/Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente). - [Gui/MainViewModel.cs](src/agent-wiki-ui/Gui/MainViewModel.cs): stato e logica della chat (invio, apertura/eliminazione, salvataggio subito all'invio e di nuovo alla risposta, riapertura dell'ultima chat all'avvio, interruttore CLI: cambiare motore apre una nuova chat; aggiornamento dei CLI con un solo aggiornamento alla volta, bloccando invio e cambio motore). - [Gui/MainWindow.axaml](src/agent-wiki-ui/Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](src/agent-wiki-ui/Gui/App.axaml): token di colore come risorse di tema chiaro/scuro e stili; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione. -- [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); le immagini si mostrano solo da file locale (ADR-0006), mai da URL. +- [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); le immagini si mostrano solo da file locale (ADR-0006), mai da URL; i percorsi relativi si cercano nella cartella dell'exe e poi nelle superiori (radice della wiki). - Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig. ## Vincoli non ovvi diff --git a/README.md b/README.md index c63b37a..5abe5cd 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,16 @@ Verificato: da Linux/WSL entrambi gli eseguibili; da Windows nativo (PowerShell - Al primo avvio le librerie native (Skia, HarfBuzz) vengono estratte in una cartella temporanea: il primo avvio è un po' più lento. - I motori (`claude`, `codex`) devono essere installati e nel PATH dello stesso sistema: vedi [docs/INSTALL.md](docs/INSTALL.md). +## Immagini nelle risposte (wiki con catalogo di figure) +Se la risposta contiene `![didascalia](percorso)` e il file esiste, la chat mostra l'immagine; altrimenti compare il testo `[didascalia]`. Gli URL `http(s)` non si scaricano. Un percorso relativo si cerca nella cartella dell'eseguibile e poi in quelle superiori, quindi funziona con una **wiki** come `cyber-tesi`: +``` +cyber-tesi/ radice del progetto, con CLAUDE.md (le istruzioni per il motore) + raw/immagini.json catalogo: metadati di ogni figura (file, didascalia, posizione, avvertenze) + raw/immagini/.jpg cartella delle immagini + agent-wiki-ui/agent-wiki-ui l'eseguibile (il motore parte qui e legge il CLAUDE.md della cartella superiore) +``` +Il catalogo lo legge il motore, non l'app: nel `CLAUDE.md` si chiede di trovare la figura nel catalogo e di citarla con un percorso relativo alla radice, per esempio `![Figura 1](raw/immagini/.jpg)`, con sotto la didascalia, la posizione e il link alla nota della figura. L'app mostra l'immagine e lascia la didascalia come testo markdown. Dettagli: [ADR-0006](docs/adr/0006-immagini-nel-markdown.md). + ## Piattaforme Windows e Linux con lo stesso codice ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md)). Motori supportati: Claude Code e Codex CLI, già installati da terminale; Antigravity CLI è pianificato: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md). diff --git a/docs/MVP.md b/docs/MVP.md index 6329908..901ae86 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -21,7 +21,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile **Struttura della finestra** - **Barra laterale** (sinistra, comprimibile con il pulsante a tre linee): logo e nome dell'app, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata". -- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link, immagini. Le immagini `![alt](percorso)` si mostrano se il percorso è locale (assoluto, relativo alla cartella dell'exe o `file://`) e il file è un'immagine leggibile entro i limiti (caricata in background: prima compare `[alt]`, poi l'immagine); altrimenti, e sempre per gli URL `http(s)`, compare il testo alternativo `[alt]` e non si scarica nulla ([ADR-0006](adr/0006-immagini-nel-markdown.md)). Il rendering è un renderer proprio ([MarkdownView.cs](../src/agent-wiki-ui/Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice. +- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link, immagini. Le immagini `![alt](percorso)` si mostrano se il percorso è locale (assoluto, `file://` o relativo: cercato nella cartella dell'exe e poi nelle cartelle superiori, cioè nella radice del progetto del motore) e il file è un'immagine leggibile entro i limiti (caricata in background: prima compare `[alt]`, poi l'immagine); altrimenti, e sempre per gli URL `http(s)`, compare il testo alternativo `[alt]` e non si scarica nulla ([ADR-0006](adr/0006-immagini-nel-markdown.md)). Il rendering è un renderer proprio ([MarkdownView.cs](../src/agent-wiki-ui/Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice. - **Schermata iniziale** (chat vuota): solo logo, titolo e sottotitolo (nessun suggerimento precompilato). - **Composer** (in basso, scheda arrotondata): campo di testo che cresce fino a ~8 righe e pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. Non contiene più i selettori di modello ed effort. - **Impostazioni** (in alto a destra, pulsante con icona a cursori, simmetrico a quello della barra laterale): aprono un riquadro a comparsa con l'interruttore **CLI** (Claude Code, Codex; un motore non installato è mostrato "non installato" e non è selezionabile; disabilitato durante l'attesa), poi **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max). **Valori iniziali**: il modello più piccolo del motore (Claude Code: Haiku 5.5; Codex: GPT-5.6 Luna) ed effort `low`, per risposte rapide e poco costose; sono quelli che si ritrovano all'avvio e a ogni cambio di CLI. Si chiude con `Esc` o con un clic fuori; le scelte restano valide per i messaggi successivi (non sono ancora salvate su disco). Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index edba59d..03a642d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,7 +22,7 @@ Dettagli dell'MVP in [MVP.md](MVP.md). Stadio attuale e debiti: [PROJECT_STATE.m ## Fase 3 — Oltre - [x] GUI: cronologia chat (salvataggio JSON, barra laterale, riapertura con `--resume`, eliminazione) - [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, rinomina/ricerca chat -- [x] GUI: immagini nel markdown da percorso locale, con ripiego sul testo alternativo ([ADR-0006](adr/0006-immagini-nel-markdown.md); test MD-14, MD-19…MD-27); immagini remote rimandate +- [x] GUI: immagini nel markdown da percorso locale, con ripiego sul testo alternativo ([ADR-0006](adr/0006-immagini-nel-markdown.md); test MD-14, MD-19…MD-30); immagini remote rimandate - [ ] Config file accanto all'exe - [ ] Test automatici e CI (vedi Fase 6) diff --git a/docs/TESTING.md b/docs/TESTING.md index d6033ec..458f78f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -173,6 +173,9 @@ Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`; | MD-25 | Immagine dentro un link, in una lista o in una tabella ⇒ resa senza rompere intervalli dei link (MD-13) né il layout | R | `Immagine_in_contesti_annidati` | [x] | | MD-26 | Cambio di tema con immagini presenti ⇒ vista ricostruita, immagini ancora mostrate | R | `Immagine_cambio_tema` | [x] | | MD-27 | Il caricamento è in background: subito dopo il rendering c'è il segnaposto `[alt]` e un'immagine in attesa; a fine decodifica compare l'immagine; un file corrotto lascia `[alt]` | R | `Immagine_caricamento_in_background` | [x] | +| MD-28 | Percorso relativo non presente nella cartella dell'exe ma in una cartella superiore (radice del progetto, es. `raw/immagini/` con l'exe in una sottocartella) ⇒ immagine mostrata | R | `Immagine_percorso_relativo_alla_radice` | [x] | +| MD-29 | Se il file esiste in più cartelle, vince la più vicina all'exe; se non esiste in nessuna ⇒ testo alternativo | R | `Immagine_cartella_piu_vicina` | [x] | +| MD-30 | Struttura tipo wiki (catalogo JSON + cartella immagini + risposta con immagine, didascalia e link `[[fig-NNN]]` sotto): l'immagine compare e la didascalia resta testo; l'app non legge il JSON | R | `Immagine_risposta_stile_wiki` | [x] | | MD-15 | HTML grezzo disabilitato: i tag compaiono come testo, non come elementi | R | `Html_disabilitato` | [ ] | | MD-16 | Cambio di tema (chiaro/scuro): i colori si aggiornano ricostruendo la vista | R | `Cambio_tema` | [ ] | | MD-17 | Markdown malformato o blocchi sconosciuti non fanno crashare | R | `Markdown_malformato` | [ ] | diff --git a/docs/adr/0006-immagini-nel-markdown.md b/docs/adr/0006-immagini-nel-markdown.md index fad4b1e..5d0fc5c 100644 --- a/docs/adr/0006-immagini-nel-markdown.md +++ b/docs/adr/0006-immagini-nel-markdown.md @@ -21,7 +21,9 @@ Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/Markdown | Locali + URL `http(s)` | Rimandata | Una risposta del modello farebbe partire richieste di rete verso indirizzi scelti dal modello (tracciamento, indirizzo IP dell'utente esposto). Si può riprendere con un nuovo ADR e un consenso esplicito. | ## Decisione -- `![alt](destinazione)` mostra l'immagine se la destinazione è un **percorso locale**: assoluto, relativo alla cartella dell'exe (`AppContext.BaseDirectory`, la stessa working directory dei motori) oppure un URI `file://`. +- `![alt](destinazione)` mostra l'immagine se la destinazione è un **percorso locale**: assoluto, relativo oppure un URI `file://`. +- **Percorsi relativi: radice del progetto.** Il motore gira nella cartella dell'exe, ma le istruzioni che segue (il `CLAUDE.md` di una wiki) stanno di solito in una cartella superiore e scrivono i percorsi relativi a *quella* radice (esempio: la wiki `cyber-tesi`, con l'exe in `cyber-tesi/agent-wiki-ui/` e le istruzioni «`![Figura N](raw/immagini/)`, percorso relativo alla radice del repo»). Un percorso relativo si cerca quindi nella cartella dell'exe e poi in ciascuna cartella superiore, fino alla radice del filesystem; vince la prima in cui il file esiste (la più vicina all'exe). +- **Cataloghi e metadati restano del motore.** Struttura tipo `cyber-tesi`: un catalogo JSON con i metadati delle figure (`raw/immagini.json`: file, didascalia stampata e generata, posizione, avvertenze) e una cartella delle immagini (`raw/immagini/`). Chi legge il catalogo e sceglie le figure è il motore, guidato dalle istruzioni della wiki; l'app non legge né interpreta il catalogo: mostra le immagini che la risposta cita in markdown, e la didascalia, la posizione e le avvertenze che il motore scrive sotto l'immagine sono testo markdown normale. - Formati: png, jpg/jpeg, gif (primo fotogramma), webp, bmp. Altre estensioni ⇒ ripiego. - Limiti: file fino a **20 MB**; se l'immagine è più larga di **1600 px** si decodifica a 1600 px di larghezza, per non occupare memoria inutile. In visualizzazione l'immagine non supera la larghezza della colonna dei messaggi (proporzioni mantenute) e non è mai ingrandita oltre l'originale. - Un paragrafo fatto solo di immagini le mostra come blocchi; un'immagine dentro una riga di testo (o dentro un link) è inline, con larghezza massima di 480 px. @@ -37,7 +39,7 @@ Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/Markdown ## Da verificare - Quali formati decodifica davvero Avalonia 12 con Skia su Windows e Linux (in particolare webp e gif) e se il limite di 20 MB è adeguato. - Il comportamento dei percorsi Windows (`C:\…`, UNC) e di quelli WSL (`/mnt/c/…`). -- Se i percorsi relativi vanno risolti anche rispetto a una cartella diversa da quella dell'exe. +- Se la ricerca nelle cartelle superiori debba fermarsi alla prima cartella con un marcatore di progetto (`CLAUDE.md`, `.git`) invece di salire fino alla radice del filesystem. ## Riferimenti -- [MVP.md](../MVP.md) → "GUI (chat)"; matrice MD-14, MD-19…MD-27 in [TESTING.md](../TESTING.md). +- [MVP.md](../MVP.md) → "GUI (chat)"; matrice MD-14, MD-19…MD-30 in [TESTING.md](../TESTING.md). diff --git a/src/agent-wiki-ui/Gui/MarkdownView.cs b/src/agent-wiki-ui/Gui/MarkdownView.cs index abee451..a85e37f 100644 --- a/src/agent-wiki-ui/Gui/MarkdownView.cs +++ b/src/agent-wiki-ui/Gui/MarkdownView.cs @@ -29,6 +29,9 @@ class MarkdownView : Decorator /// Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero. internal int PendingImages { get; private set; } + /// Cartella da cui si cercano i percorsi relativi delle immagini: l'exe, poi le cartelle superiori (ADR-0006). Modificabile dai test. + internal string BaseDirectory { get; set; } = AppContext.BaseDirectory; + public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); } public MarkdownView() @@ -134,20 +137,27 @@ class MarkdownView : Decorator static string AltText(LinkInline img) => string.Concat(img.OfType().Select(l => l.Content.ToString())); - /// Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi. - internal static string? ResolveImagePath(string? url) + /// Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi. + /// Un percorso relativo si cerca in e poi nelle cartelle superiori (radice del progetto del motore): vince la prima in cui il file esiste. + internal static string? ResolveImagePath(string? url, string baseDir) { if (string.IsNullOrWhiteSpace(url)) return null; if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri.IsFile ? uri.LocalPath : null; - return Path.GetFullPath(Uri.UnescapeDataString(url), AppContext.BaseDirectory); + var relative = Uri.UnescapeDataString(url); + var first = Path.GetFullPath(relative, baseDir); + for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent) + { + var candidate = Path.GetFullPath(relative, dir.FullName); + if (File.Exists(candidate)) return candidate; + } + return first; // non esiste da nessuna parte: il controllo successivo lo scarta } /// Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica. - static bool IsImageCandidate(string? url) + static bool IsImageCandidate(string? path) { try { - var path = ResolveImagePath(url); if (path is null || !ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) return false; var info = new FileInfo(path); return info.Exists && info.Length <= MaxImageBytes; @@ -156,11 +166,10 @@ class MarkdownView : Decorator } /// Decodifica l'immagine (chiamata fuori dal thread della GUI); nullo se illeggibile, mai eccezioni. - static Bitmap? DecodeImage(string? url) + static Bitmap? DecodeImage(string path) { try { - if (ResolveImagePath(url) is not { } path) return null; using var fs = File.OpenRead(path); var bmp = new Bitmap(fs); if (bmp.PixelSize.Width <= MaxDecodeWidth) return bmp; @@ -172,19 +181,19 @@ class MarkdownView : Decorator } // Segnaposto `[alt]` che diventa l'immagine a decodifica finita; se la decodifica fallisce resta il testo. - Control ImageHost(string? url, string alt, double maxWidth) + Control ImageHost(string path, string alt, double maxWidth) { var host = new Decorator { HorizontalAlignment = HorizontalAlignment.Left, Child = new TextBlock { Text = "[" + alt + "]", TextWrapping = TextWrapping.Wrap } }; PendingImages++; - _ = LoadInto(host, url, alt, maxWidth); + _ = LoadInto(host, path, alt, maxWidth); return host; } - async Task LoadInto(Decorator host, string? url, string alt, double maxWidth) + async Task LoadInto(Decorator host, string path, string alt, double maxWidth) { try { - if (await Task.Run(() => DecodeImage(url)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth); + if (await Task.Run(() => DecodeImage(path)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth); } finally { PendingImages--; } } @@ -212,11 +221,15 @@ class MarkdownView : Decorator else if (node is not LineBreakInline && !(node is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) return null; } if (images.Count == 0) return null; - if (!images.Any(i => IsImageCandidate(i.Url))) return null; + var paths = images.Select(i => ResolveImagePath(i.Url, BaseDirectory)).ToList(); + if (!paths.Any(IsImageCandidate)) return null; var panel = new StackPanel { Spacing = 8 }; - foreach (var image in images) - panel.Children.Add(IsImageCandidate(image.Url) ? ImageHost(image.Url, AltText(image), double.PositiveInfinity) + for (var i = 0; i < images.Count; i++) + { + var image = images[i]; + panel.Children.Add(IsImageCandidate(paths[i]) ? ImageHost(paths[i]!, AltText(image), double.PositiveInfinity) : new SelectableTextBlock { Text = "[" + AltText(image) + "]", TextWrapping = TextWrapping.Wrap }); + } return panel; } @@ -254,9 +267,9 @@ class MarkdownView : Decorator AddInlines(target, em, inner, links, ref pos); break; case LinkInline { IsImage: true } img: - if (IsImageCandidate(img.Url)) + if (ResolveImagePath(img.Url, BaseDirectory) is { } imgPath && IsImageCandidate(imgPath)) { - target.Add(new InlineUIContainer(ImageHost(img.Url, AltText(img), MaxInlineImageWidth))); + target.Add(new InlineUIContainer(ImageHost(imgPath, AltText(img), MaxInlineImageWidth))); pos += 1; // un controllo incorporato occupa una posizione nel testo } else Add(target, "[" + AltText(img) + "]", st, ref pos); diff --git a/tests/agent-wiki-ui.Tests/MarkdownImageTests.cs b/tests/agent-wiki-ui.Tests/MarkdownImageTests.cs index 35735bb..a745f03 100644 --- a/tests/agent-wiki-ui.Tests/MarkdownImageTests.cs +++ b/tests/agent-wiki-ui.Tests/MarkdownImageTests.cs @@ -31,9 +31,10 @@ public sealed class MarkdownImageTests : IDisposable return path; } - static (Window Window, MarkdownView View) Show(string markdown, double width = 500) + static (Window Window, MarkdownView View) Show(string markdown, double width = 500, string? baseDir = null) { var view = new MarkdownView(); + if (baseDir is not null) view.BaseDirectory = baseDir; var window = new Window { Width = width, Height = 800, Content = view }; window.Show(); view.Source = markdown; @@ -205,4 +206,60 @@ public sealed class MarkdownImageTests : IDisposable Assert.Empty(Images(view)); Assert.Contains("[guasta]", Text(view)); } + + // Struttura tipo wiki: /raw/immagini.json, /raw/immagini/, exe in /agent-wiki-ui/ + string Wiki(string name, params string[] files) + { + var root = Path.Combine(dir, name); + Directory.CreateDirectory(Path.Combine(root, "raw", "immagini")); + Directory.CreateDirectory(Path.Combine(root, "agent-wiki-ui")); + foreach (var f in files) File.WriteAllBytes(Path.Combine(root, "raw", "immagini", f), PngFactory.Create(24, 12)); + File.WriteAllText(Path.Combine(root, "raw", "immagini.json"), "{\"figures\":[]}"); + return root; + } + + [AvaloniaFact] + public void Immagine_percorso_relativo_alla_radice() + { + var root = Wiki("wiki-a", "fig1.png"); + var (_, view) = Show("![Figura 1](raw/immagini/fig1.png)", baseDir: Path.Combine(root, "agent-wiki-ui")); + Assert.Single(Images(view)); + Assert.DoesNotContain("[Figura 1]", Text(view)); + } + + [AvaloniaFact] + public void Immagine_cartella_piu_vicina() + { + var root = Wiki("wiki-b"); + File.WriteAllBytes(Path.Combine(root, "raw", "immagini", "x.png"), PngFactory.Create(30, 30)); + var exeDir = Path.Combine(root, "agent-wiki-ui"); + Directory.CreateDirectory(Path.Combine(exeDir, "raw", "immagini")); + File.WriteAllBytes(Path.Combine(exeDir, "raw", "immagini", "x.png"), PngFactory.Create(10, 10)); + + var (_, view) = Show("![x](raw/immagini/x.png)", baseDir: exeDir); + var bmp = Assert.IsAssignableFrom(Assert.Single(Images(view)).Source); + Assert.Equal(new PixelSize(10, 10), bmp.PixelSize); // vince la cartella più vicina all'exe + + var (_, nowhere) = Show("![y](raw/immagini/non-esiste.png)", baseDir: exeDir); + Assert.Empty(Images(nowhere)); + Assert.Contains("[y]", Text(nowhere)); + } + + [AvaloniaFact] + public void Immagine_risposta_stile_wiki() + { + var root = Wiki("wiki-c", "455dd3d0.png"); + var risposta = """ + La struttura generica è in Figura 1. + + ![Figura 1](raw/immagini/455dd3d0.png) + + Figura 1 - struttura generica di un dichetopirrolopirrolo · §1.1 r.73 · [[fig-001]] + """; + var (_, view) = Show(risposta, baseDir: Path.Combine(root, "agent-wiki-ui")); + Assert.Single(Images(view)); + var text = Text(view); + Assert.Contains("Figura 1 - struttura generica", text); + Assert.Contains("[[fig-001]]", text); // il link alla nota resta testo: l'app non legge il catalogo JSON + } }