Merge del branch formule-latex: formule LaTeX nel markdown

This commit is contained in:
davide committed 2026-10-20 15:31:28 +02:00
commit 28201fbbda
24 files changed
+723 -116

No files matched your search

+2 -2
View File
@@ -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; `tests/FakeClaude` è il `claude` finto per i test di processo). 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`); la parte sulle immagini è in [Gui/MarkdownView.Images.cs](src/agent-wiki-ui/Gui/MarkdownView.Images.cs) (`partial`); le formule LaTeX (ADR-0008) sono in [Gui/MarkdownView.Math.cs](src/agent-wiki-ui/Gui/MarkdownView.Math.cs) (`partial`, libreria `Sylinko.CSharpMath.Avalonia`). 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
+6
View File
@@ -61,6 +61,12 @@ 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).
## Formule nelle risposte
Le formule LaTeX si vedono composte: `$ ... $` nel testo e `$$ ... $$` a blocco ([ADR-0008](docs/adr/0008-formule-latex-nel-markdown.md)). Se il LaTeX non è valido compare il sorgente come testo; i `$` isolati (per esempio «10 $ e 20 $») restano testo. Le formule non sono selezionabili come testo, ma il pulsante **Copia** della risposta copia il markdown con il sorgente.
## Immagini nelle risposte
L'app mostra il file indicato dal percorso scritto nella risposta, ovunque stia; non conosce né cartelle né cataloghi di immagini. 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 anche se l'eseguibile sta in una sottocartella del progetto. Dettagli: [ADR-0006](docs/adr/0006-immagini-nel-markdown.md); per una convenzione di progetto interrogabile con immagini vedi [ADR-0007](docs/adr/0007-schema-wiki-per-query-con-immagini.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).
+1
View File
@@ -4,5 +4,6 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj" />
<Project Path="tests/FakeClaude/FakeClaude.csproj" />
</Folder>
</Solution>
+2 -2
View File
@@ -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, formule LaTeX (`$…$` nel testo, `$$…$$` a blocco; [ADR-0008](adr/0008-formule-latex-nel-markdown.md); se il LaTeX non è valido compare il sorgente). 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.
@@ -46,7 +46,7 @@ Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "
## Scelte tecniche
Le scelte architetturali sono negli [ADR](README.md#indice-adr). Sintesi:
- .NET 10, un solo progetto `net10.0` per Windows e Linux. Avalonia (GUI), Markdig (parsing markdown). Il codice è diviso in `Core/` (avvio di `claude`, cronologia, nome app) e `Gui/` (finestra, ViewModel, renderer markdown); `Program.cs` sceglie CLI o GUI.
- .NET 10, un solo progetto `net10.0` per Windows e Linux. Avalonia (GUI), Markdig (parsing markdown), CSharpMath (formule LaTeX). Il codice è diviso in `Core/` (avvio di `claude`, cronologia, nome app) e `Gui/` (finestra, ViewModel, renderer markdown); `Program.cs` sceglie CLI o GUI.
- `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting).
- `claude` è un eseguibile nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude.
- Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante.
+2
View File
@@ -43,6 +43,8 @@ Da [MVP.md](MVP.md). Restano non spuntati finché non hanno test automatici (reg
| [0004](adr/0004-aggiornamento-dei-cli.md) Aggiornamento dei CLI | Proposta |
| [0005](adr/0005-windows-senza-console.md) Windows senza console | Proposta |
| [0006](adr/0006-immagini-nel-markdown.md) Immagini nel markdown (solo file locali) | Proposta (implementata, test verdi) |
| [0007](adr/0007-schema-wiki-per-query-con-immagini.md) Schema di una wiki interrogabile, con immagini | Proposta (solo documentazione; nessun codice) |
| [0008](adr/0008-formule-latex-nel-markdown.md) Formule LaTeX nel markdown | Proposta (implementata) |
## Per chiudere l'MVP
Proposta, derivata dai punti aperti sopra; da confermare.
+2
View File
@@ -53,3 +53,5 @@ Non si scrive nulla nei puntatori: ogni modifica va in AGENT.md. Per un altro st
| [0004](adr/0004-aggiornamento-dei-cli.md) | Aggiornamento dei CLI dalle Impostazioni | Proposta |
| [0005](adr/0005-windows-senza-console.md) | Windows: eseguibile senza finestra di console | Proposta |
| [0006](adr/0006-immagini-nel-markdown.md) | Immagini nel markdown: solo file locali | Proposta |
| [0007](adr/0007-schema-wiki-per-query-con-immagini.md) | Schema di una wiki interrogabile, con immagini | Proposta |
| [0008](adr/0008-formule-latex-nel-markdown.md) | Formule LaTeX nel markdown | Proposta |
+5 -3
View File
@@ -22,7 +22,9 @@ 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
- [x] Schema di una wiki interrogabile con immagini: convenzione per le istruzioni solo-query, l'indice e il catalogo ([ADR-0007](adr/0007-schema-wiki-per-query-con-immagini.md)); da provare con la chat reale su una wiki di esempio
- [x] GUI: formule LaTeX nel markdown, inline `$…$` e a blocco `$$…$$`, con ripiego sul sorgente ([ADR-0008](adr/0008-formule-latex-nel-markdown.md); test MD-31…MD-38)
- [ ] Config file accanto all'exe
- [ ] Test automatici e CI (vedi Fase 6)
@@ -65,9 +67,9 @@ Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md).
Decisione: [ADR-0003](adr/0003-strategia-di-test.md). Piano, soglie e matrice: [TESTING.md](TESTING.md). Da questa fase in poi vale la regola: ogni nuova funzione arriva con i suoi test.
- [ ] Verificare le compatibilità dell'ADR-0003 (xUnit, Avalonia.Headless, coverlet, `dotnet test`) e portarlo ad "Accettata"
- [ ] Ristrutturare in `src/` e `tests/` con soluzione unica e `InternalsVisibleTo`
- [x] Ristrutturare in `src/` e `tests/` con soluzione unica e `InternalsVisibleTo`
- [ ] Predisposizioni di testabilità: `ChatStore` a istanza, `IClaudeRunner`
- [ ] `FakeClaude`: eseguibile finto per i test di processo
- [x] `FakeClaude`: eseguibile finto per i test di processo (`tests/FakeClaude`, prove FK-01…FK-05)
- [ ] Test `Core/` (CL, ST) e `MainViewModel` (VM)
- [ ] Test del renderer markdown (MD) e della GUI headless (UI)
- [ ] Test end-to-end e smoke di publish per `win-x64` e `linux-x64` (PUB)
+37 -3
View File
@@ -21,13 +21,27 @@ ADR (se serve) → MVP.md (requisito e criterio) → riga in questa matrice →
| **P** — processo | avvio reale di un processo (`Claude`) | `FakeClaude` nel PATH |
| **E** — end-to-end | l'eseguibile pubblicato (CLI, 127, exit code) | publish + processo figlio + `FakeClaude` |
## Struttura prevista
## Struttura
```
agent-wiki-ui.slnx
src/agent-wiki-ui/ app (Core/, Gui/, Program.cs)
tests/agent-wiki-ui.Tests/ U, R, G, P, E (Trait "Category": Unit | Gui | Process | E2E)
tests/FakeClaude/ eseguibile finto: registra argv/cwd/stdin; testo, stderr, exit code e ritardo da variabili d'ambiente
tests/agent-wiki-ui.Tests/ Support/ (TestApp, PngFactory), Gui/ (R, G), Process/ (P), Core/ (U, quando arrivano), E2E/ (E)
tests/FakeClaude/ eseguibile finto `claude` (vedi sotto)
```
Una cartella per livello, creata quando c'è il primo test; il livello si dichiara anche con `[Trait("Category", "Unit" | "Gui" | "Process" | "E2E")]`.
### FakeClaude
Piccolo eseguibile (`AssemblyName` `claude`, nel PATH dei test) che prende il posto del vero motore. Un test lo configura con variabili d'ambiente del processo che avvia:
| Variabile | Effetto |
|---|---|
| `FAKE_CLAUDE_LOG` | file in cui accoda una riga JSON per invocazione: `{"args": [...], "cwd": "...", "stdin": "..."}` |
| `FAKE_CLAUDE_OUT` | testo scritto su stdout (anche per `--version`) |
| `FAKE_CLAUDE_ERR` | testo scritto su stderr |
| `FAKE_CLAUDE_EXIT` | exit code (predefinito 0) |
| `FAKE_CLAUDE_DELAY_MS` | attesa prima di rispondere |
Legge stdin fino alla fine e lo registra: se il chiamante non lo chiude, il processo non termina (così CL-05 è verificabile). Il percorso del file compilato lo fornisce l'helper di test `FakeClaudePath`.
## Predisposizioni di testabilità (prerequisito dei test)
- [ ] `ChatStore` come istanza con cartella iniettata (oggi statico su `LocalApplicationData`)
@@ -173,6 +187,17 @@ 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. `<cartella>/<file>` 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 | Risposta con immagine e, sotto, didascalia e link in markdown: l'immagine compare e la didascalia resta testo; l'app non legge nessun catalogo | R | `Immagine_risposta_con_didascalia` | [x] |
| MD-31 | Formula inline `$…$` ⇒ controllo formula inserito nel testo; il testo prima e dopo resta intatto e non sparisce più (ADR-0008) | R | `Formula_inline` | [x] |
| MD-32 | Formula a blocco `$$…$$` ⇒ controllo formula (non un blocco di codice col sorgente) | R | `Formula_a_blocco` | [x] |
| MD-33 | LaTeX non valido (inline e a blocco) ⇒ sorgente come testo, nessuna eccezione | R | `Formula_non_valida_ripiego` | [x] |
| MD-34 | Costrutti LaTeX comuni (frazioni, apici e pedici, `\mathbf`, `\overline`, `\times`, lettere greche, `\left…\right`, `\sum`) ⇒ nessun errore | R | `Costrutti_latex_comuni` | [x] |
| MD-35 | Cambio di tema con formule presenti ⇒ vista ricostruita, formule ancora mostrate | R | `Formula_cambio_tema` | [x] |
| MD-36 | Formula dentro una lista, una tabella o un link ⇒ resa senza rompere il layout né gli intervalli dei link (MD-13) | R | `Formula_in_contesti_annidati` | [x] |
| MD-37 | Il nome accessibile della formula è il suo sorgente LaTeX | R | `Formula_nome_accessibile` | [x] |
| MD-38 | Simboli `$` isolati (es. prezzi «10 $ e 20 $») restano testo, nessuna formula | R | `Dollari_isolati_restano_testo` | [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` | [ ] |
@@ -239,6 +264,15 @@ Per **ogni** motore, la stessa batteria di contratto:
| EN-17 | CLI `--engine <id> "prompt"`; id sconosciuto o prompt mancante ⇒ uso su stderr ed exit 2; senza `--engine` ⇒ Claude Code | E | [ ] |
| EN-18 | Rilevamento nel PATH: nome esatto su Linux, `.exe` su Windows; cartella inesistente nel PATH ignorata | U | [ ] |
### FakeClaude — `tests/FakeClaude`
| ID | Requisito | Liv. | Test | |
|---|---|---|---|---|
| FK-01 | Registra argomenti (anche con virgolette e spazi, uno per elemento), cartella di lavoro e stdin | P | `Registra_argomenti_cwd_e_stdin` | [x] |
| FK-02 | Scrive su stdout e stderr il testo configurato | P | `Output_e_stderr_configurati` | [x] |
| FK-03 | L'exit code configurato è quello del processo | P | `ExitCode_configurato` | [x] |
| FK-04 | Il ritardo configurato è rispettato | P | `Ritardo_configurato` | [x] |
| FK-05 | Termina solo dopo la chiusura di stdin | P | `Attende_la_chiusura_di_stdin` | [x] |
## Come si eseguono (a implementazione avvenuta)
Comandi previsti, da confermare con ADR-0003 → "Da verificare":
- Tutti i test: `dotnet test`
+7 -3
View File
@@ -21,7 +21,10 @@ 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: una wiki con l'exe in `<wiki>/query/`, accanto a un `CLAUDE.md` solo-query, e istruzioni del tipo «`![Figura N](<cartella-immagini>/<file>)`, 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).
- **Nessuno standard imposto (prototipo).** L'app non conosce né una cartella delle immagini né un formato di catalogo: la cartella delle immagini e il catalogo sono la convenzione di ciascuna wiki, non un requisito. Cartella e catalogo possono cambiare; la wiki lo dice al motore nelle sue istruzioni, e l'app mostra qualunque file locale raggiungibile con il percorso scritto nella risposta.
- **Cataloghi e metadati restano del motore.** Struttura tipo di una wiki con figure: un catalogo JSON con i metadati delle figure (file, didascalia stampata e generata, posizione, avvertenze) e una cartella delle 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 +40,8 @@ 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.
- Se e quando emergerà uno standard per cataloghi e cartelle delle immagini (nomi, campi minimi come file, didascalia, posizione), valutare un nuovo ADR; per ora è fuori dall'app.
## 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).
@@ -0,0 +1,62 @@
# ADR-0007 — Schema di una wiki interrogabile, con immagini (prototipo)
- **Stato**: Proposta
- **Data**: 2026-10-15
- **Collegato a**: [ADR-0006](0006-immagini-nel-markdown.md) (l'app mostra le immagini citate nella risposta)
## Contesto
agent-wiki-ui è il terminale di chat per interrogare una wiki mantenuta da un motore (Claude Code, Codex), per esempio una wiki di studio o di documentazione tecnica: pagine markdown, un indice, un catalogo JSON delle figure con i metadati e una cartella di immagini. Per rispondere in modo strutturato — meglio di un RAG, che recupera frammenti di testo e perde le relazioni — il motore deve sapere **dove cercare** (indice → pagina → tabella o figura) e **come citare** un'immagine perché la chat la stampi. Oggi questo sapere sta in un file scritto per una sola wiki. Non esiste uno standard: la cartella delle immagini e il formato del catalogo cambiano da wiki a wiki, ed è un prototipo.
## Criteri di decisione
1. Un solo posto per i percorsi delle immagini, senza duplicarli a mano.
2. Le mappe sono derivate dal catalogo (si rigenerano), non scritte a mano.
3. Nessuna cartella o nome di file imposti: la wiki dichiara la sua struttura.
4. Degrado sicuro: un percorso sbagliato dà `[alt]` nella chat (ADR-0006), mai un errore.
5. Nessun codice nell'app: la logica sta nelle istruzioni al motore e negli script della wiki.
6. Pochi campi minimi, perché lo standard possa nascere dall'uso.
## Opzioni considerate
| Opzione | Esito | Motivo |
|---|---|---|
| Solo RAG sul testo (embedding di `search_text`) | Scartata come unica via | Perde la struttura che rende affidabile la risposta: dato → pagina unica, avvertenze, difetti noti, figura → file. Resta come ripiego (ricerca testuale). |
| L'app legge il catalogo e inserisce le immagini nelle risposte | Scartata | Lega l'app a un formato non standard e duplica la scelta delle figure, che il motore fa già leggendo la domanda. |
| **Schema in tre parti, letto dal motore; l'app stampa** | **Scelta** | Conserva la struttura, non tocca l'app, si adatta a wiki diverse. |
## Decisione
Una wiki interrogabile ha tre parti. Il formato è una convenzione consigliata, non un requisito dell'app.
1. **Indice** (`index.md`): catalogo delle pagine, una riga per pagina, `- [[pagina]] — sintesi`. Una pagina con figure aggiunge in coda gli id del catalogo: `· figure: fig-026, fig-027`. L'indice non elenca le figure una per una e non contiene percorsi di file.
2. **Catalogo delle figure** (JSON, nome e posizione dichiarati nello schema della wiki). Campi minimi per figura:
- `id` stabile, `label` (ad es. «Figura 26»);
- `caption_printed` (testo della fonte, alla lettera) e, a parte, ogni testo generato dall'immagine, contrassegnato come non verificato;
- `location` (sezione e riga nella fonte);
- `panels[]` con `file` e `path` (relativo alla cartella del catalogo) e una descrizione;
- `quality`: `flags` e `review_notes`.
Indici facoltativi per sezione, composto, tipo e concetto. L'ordine di preferenza per trovare una figura è: indice dell'indice → pagina → `figures` nel frontmatter → indici del catalogo → ricerca testuale.
3. **Istruzioni per le query**: un file `CLAUDE.md` (per Codex `AGENTS.md`) in una cartella dedicata sotto la radice della wiki, da cui si avvia l'app. Si aggiunge a quello della radice e prevale su di esso. Impone la **sola lettura**, instrada le domande per tipo verso l'indice giusto e fissa le regole per le immagini.
**Regole per le immagini nella risposta:**
- Il percorso si ricava da catalogo e `path`, si **verifica che esista** e si scrive **relativo alla radice** della wiki, senza `..` (l'app lo cerca nella sua cartella e poi nelle superiori, ADR-0006).
- Subito sotto: didascalia stampata alla lettera, posizione nella fonte, link alla nota della figura; una riga di avvertenza se `flags` o `review_notes`.
- Ciò che deriva dall'immagine (descrizioni, valori letti da un grafico) si dichiara non verificato. Massimo 3-4 immagini per risposta.
**Percorsi derivati.** Se il catalogo ha percorsi relativi alla propria cartella, la wiki genera con uno script una mappa figura → percorso dalla radice → pagine che la citano (per esempio colonne in più di `figure-indice.md`). Lo script, e un controllo in fase di lint che ogni percorso della mappa esista, sono della wiki, non di questo repo.
**Chi fa cosa**
| Parte | Compito |
|---|---|
| Wiki | Mantiene indice, catalogo e immagini; rigenera la mappa; controlla che i percorsi esistano |
| Motore | Instrada la domanda, legge indici e pagine, sceglie le figure, scrive il markdown con l'immagine e la didascalia |
| App | Stampa il markdown; mostra l'immagine se il file esiste, altrimenti `[alt]` |
## Conseguenze
**Positive**: una risposta ripercorre indice → pagina → figura invece di cercare frammenti; i percorsi stanno in un solo posto e si rigenerano; l'app non cambia; wiki diverse adottano lo stesso modello cambiando pochi campi.
**Negative**: il rispetto dello schema dipende dal motore che segue le istruzioni (può sbagliare percorso: l'app lo mostra come `[alt]`); la wiki deve mantenere lo script e il controllo dei percorsi; lo standard è provvisorio.
## Da verificare
- Che Claude Code, avviato dalla cartella dedicata, legga e rispetti sia il file di quella cartella sia quello della radice (prova con la chat reale su una wiki di esempio).
- L'equivalente per Codex (`AGENTS.md`) e come si comporta con le immagini nei suoi comandi.
- Se il campo minimo `path` debba diventare un percorso dalla radice nel catalogo stesso, eliminando la mappa derivata.
## Riferimenti
- Uso con le immagini: [README.md](../../README.md), sezione «Immagini nelle risposte».
@@ -0,0 +1,45 @@
# ADR-0008 — Formule LaTeX nel markdown
- **Stato**: Proposta
- **Data**: 2026-10-20
- **Collegato a**: [ADR-0006](0006-immagini-nel-markdown.md) (stesso renderer, stessi vincoli di sicurezza), [ADR-0007](0007-schema-wiki-per-query-con-immagini.md) (stesso tipo di risposte)
## Contesto
Le risposte dei motori possono contenere formule in LaTeX: `$ ... $` nel testo e `$$ ... $$` a blocco. Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/MarkdownView.cs)) usa Markdig con `UseAdvancedExtensions()`, che comprende l'estensione matematica: il parser riconosce le formule ma il renderer non le gestisce. Quelle a blocco finiscono in un blocco di codice con il sorgente LaTeX; quelle nel testo non vengono disegnate (nodo ignorato). L'utente vede sorgente illeggibile o formule mancanti.
## Criteri di decisione
1. Mostrare le formule composte (frazioni, apici e pedici, lettere greche, sommatorie, sopralineature) come in un testo tipografico.
2. Nessuna rete e nessuna esecuzione di codice: la formula è solo dato da disegnare (come per le immagini, ADR-0006).
3. Degrado sicuro: un LaTeX non valido mostra il sorgente come testo, mai un errore né un blocco della chat.
4. Coerenza con il resto del renderer: colori dal tema, ricostruzione al cambio di tema, testo circostante selezionabile, accessibilità.
5. Funziona su Windows e Linux nel publish single-file, senza runtime aggiuntivi.
6. Dipendenza manutenuta e compatibile con la versione di Avalonia in uso.
## Opzioni considerate
| Opzione | Esito | Motivo |
|---|---|---|
| Lasciare com'è | Scartata | Il sorgente LaTeX o l'assenza delle formule rendono illeggibili le risposte con formule. |
| Convertire il LaTeX in testo Unicode (`x_1` → `x`+pedice) con un mini-interprete proprio | Scartata | Copre solo i casi semplici; frazioni, `\overline`, `\left…\right` e matrici richiederebbero di riscrivere un motore di composizione. |
| Rendere con un motore web (KaTeX/MathJax in una WebView) | Scartata | Aggiunge un browser incorporato e codice JavaScript, contro il criterio 2 e il peso dell'eseguibile. |
| **Libreria di composizione TeX per Avalonia (`Sylinko.CSharpMath.Avalonia`, licenza MIT, basata su CSharpMath)** | **Scelta** | Disegna LaTeX come controllo Avalonia, senza rete né script; l'ultima versione (12.0.0) è per Avalonia 12, come l'app. |
## Decisione
- La formula tra `$ … $` è un'**immagine inline** composta dal controllo della libreria, inserita nel testo (`InlineUIContainer`) con allineamento al testo; `$$ … $$` è un **blocco** a sé, allineato a sinistra, con scorrimento orizzontale se più larga della colonna.
- Il colore è quello del testo del tema (`Fg`, o il colore ereditato se il token manca); al cambio di tema la vista si ricostruisce (già così per il resto).
- **Ripiego**: se il LaTeX non è valido la formula compare come testo sorgente (`$…$` con carattere monospazio), senza eccezioni.
- Il nome accessibile del controllo è il sorgente LaTeX; la formula non è selezionabile come testo (è un disegno): il pulsante **Copia** della risposta continua a copiare il markdown originale, quindi anche il sorgente delle formule.
- I simboli `$` isolati, ad esempio un prezzo («10 $ e 20 $»), restano testo. Markdig da solo li prenderebbe per una formula (e toglie gli spazi dal contenuto): il renderer applica la regola di Pandoc sul testo originale, cioè niente spazio subito dopo il `$` di apertura né subito prima di quello di chiusura. Per questo gli intervalli di Markdig si leggono su un testo con fine riga uniformi (`\n`).
- Nessuna rete e nessuna lettura di file: la formula è solo dato per il disegno.
## Conseguenze
**Positive**: le risposte con formule mostrano le formule composte; nessun cambiamento ai motori né ai prompt.
**Negative**: una dipendenza in più (e le sue librerie di disegno); il pacchetto è un fork per Avalonia 12 e va tenuto d'occhio quando Avalonia aggiorna; le formule non sono selezionabili come testo; il supporto non copre tutto LaTeX (le macro non supportate cadono nel ripiego).
## Da verificare
- Dimensione dell'eseguibile pubblicato (single-file) dopo l'aggiunta della libreria e dei suoi font.
- Allineamento verticale delle formule inline rispetto al testo con font diversi, su Windows e Linux.
- Quali comandi LaTeX comuni (matrici, ambienti `align`, estensioni a pacchetto) sono supportati.
- Il comportamento con formule molto lunghe in una riga di testo (a capo).
## Riferimenti
- [MVP.md](../MVP.md) → "GUI (chat)"; matrice MD-31…MD-38 in [TESTING.md](../TESTING.md).
@@ -0,0 +1,124 @@
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace AgentWikiUi.Gui;
/// <summary>Immagini nel markdown (ADR-0006): solo file locali, caricamento in background, ripiego sul testo alternativo.</summary>
partial class MarkdownView
{
// Immagini (ADR-0006): solo file locali, entro questi limiti
const long MaxImageBytes = 20L * 1024 * 1024;
const int MaxDecodeWidth = 1600;
const double MaxInlineImageWidth = 480;
static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"];
/// <summary>Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero.</summary>
internal int PendingImages { get; private set; }
/// <summary>Cartella da cui si cercano i percorsi relativi delle immagini: l'exe, poi le cartelle superiori (ADR-0006). Modificabile dai test.</summary>
internal string BaseDirectory { get; set; } = AppContext.BaseDirectory;
static string AltText(LinkInline img) => string.Concat(img.OfType<LiteralInline>().Select(l => l.Content.ToString()));
/// <summary>Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi.
/// Un percorso relativo si cerca in <paramref name="baseDir"/> e poi nelle cartelle superiori (radice del progetto del motore): vince la prima in cui il file esiste.</summary>
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;
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
}
/// <summary>Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica.</summary>
static bool IsImageCandidate(string? path)
{
try
{
if (path is null || !ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) return false;
var info = new FileInfo(path);
return info.Exists && info.Length <= MaxImageBytes;
}
catch { return false; }
}
/// <summary>Decodifica l'immagine (chiamata fuori dal thread della GUI); nullo se illeggibile, mai eccezioni.</summary>
static Bitmap? DecodeImage(string path)
{
try
{
using var fs = File.OpenRead(path);
var bmp = new Bitmap(fs);
if (bmp.PixelSize.Width <= MaxDecodeWidth) return bmp;
bmp.Dispose();
fs.Position = 0;
return Bitmap.DecodeToWidth(fs, MaxDecodeWidth);
}
catch { return null; }
}
// Segnaposto `[alt]` che diventa l'immagine a decodifica finita; se la decodifica fallisce resta il testo.
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, path, alt, maxWidth);
return host;
}
async Task LoadInto(Decorator host, string path, string alt, double maxWidth)
{
try
{
if (await Task.Run(() => DecodeImage(path)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth);
}
finally { PendingImages--; }
}
static Image ImageOf(Bitmap bmp, string alt, double maxWidth)
{
var img = new Image
{
Source = bmp, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly,
MaxWidth = Math.Min(maxWidth, bmp.Size.Width), HorizontalAlignment = HorizontalAlignment.Left,
};
AutomationProperties.SetName(img, alt);
ToolTip.SetTip(img, alt);
return img;
}
// Paragrafo fatto solo di immagini: blocchi alla larghezza della colonna. Nullo se non è così o nessuna è plausibile.
Control? ImageParagraph(ParagraphBlock p)
{
if (p.Inline is null) return null;
var images = new List<LinkInline>();
foreach (var node in p.Inline)
{
if (node is LinkInline { IsImage: true } img) images.Add(img);
else if (node is not LineBreakInline && !(node is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) return null;
}
if (images.Count == 0) return null;
var paths = images.Select(i => ResolveImagePath(i.Url, BaseDirectory)).ToList();
if (!paths.Any(IsImageCandidate)) return null;
var panel = new StackPanel { Spacing = 8 };
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;
}
}
@@ -0,0 +1,72 @@
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
using CSharpMath.Avalonia;
using Markdig.Extensions.Mathematics;
using Markdig.Syntax;
namespace AgentWikiUi.Gui;
/// <summary>Formule LaTeX nel markdown (ADR-0008): inline <c>$…$</c> e a blocco <c>$$…$$</c>; se il LaTeX non è valido compare il sorgente.</summary>
partial class MarkdownView
{
const double InlineFormulaSize = 15;
const double BlockFormulaSize = 18;
/// <summary>Sorgente LaTeX di un blocco <c>$$…$$</c> (righe tra i delimitatori).</summary>
static string BlockSource(LeafBlock block) =>
string.Join('\n', block.Lines.Lines.Take(block.Lines.Count).Select(l => l.ToString())).Trim();
Control FormulaBlock(string latex)
{
var formula = Formula(latex, BlockFormulaSize, "$$");
// formula più larga della colonna: scorrimento orizzontale invece di tagliarla
return new ScrollViewer
{
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
HorizontalAlignment = HorizontalAlignment.Left, Content = formula,
};
}
// colore del testo del tema (token Fg); senza il token (es. nei test) quello ereditato dal controllo
Color TextColor() =>
this.TryFindResource("Fg", ActualThemeVariant, out var v) && v is ISolidColorBrush s ? s.Color
: TextElement.GetForeground(this) is ISolidColorBrush f ? f.Color : Colors.Black;
/// <summary>Regola di Pandoc per <c>$…$</c>: niente spazio dopo l'apertura né prima della chiusura, altrimenti i due <c>$</c> sono testo (prezzi, importi). Markdig da solo accetterebbe «10 $ e 20 $».</summary>
static bool IsDollarText(string source, MathInline math)
{
// Markdig toglie gli spazi dal contenuto: si controlla il testo originale tra i due delimitatori
if (math.DelimiterCount != 1) return false;
var (start, end) = (math.Span.Start, math.Span.End);
if (start < 0 || end >= source.Length || end - start < 2) return false; // intervallo non affidabile: si tratta come formula
return char.IsWhiteSpace(source[start + 1]) || char.IsWhiteSpace(source[end - 1]);
}
Control FormulaInline(string latex) => Formula(latex, InlineFormulaSize, "$");
/// <summary>Controllo che disegna la formula; se il LaTeX non è valido, il sorgente come testo monospazio (ripiego sicuro).</summary>
Control Formula(string latex, double size, string delimiter)
{
latex = latex.Trim();
try
{
var view = new MathView
{
LaTeX = latex, FontSize = (float)size, TextColor = TextColor(), DisplayErrorInline = false,
VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Left,
};
if (latex.Length > 0 && string.IsNullOrEmpty(view.ErrorMessage))
{
AutomationProperties.SetName(view, latex);
ToolTip.SetTip(view, latex);
return view;
}
}
catch (Exception) { /* LaTeX o disegno non riusciti: ripiego sul sorgente */ }
return new TextBlock { Text = delimiter + latex + delimiter, FontFamily = Mono, FontSize = 13.5, Foreground = Brush("FgMuted"), TextWrapping = TextWrapping.Wrap };
}
}
+23 -102
View File
@@ -1,13 +1,12 @@
using Avalonia;
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Markdig;
using Markdig.Extensions.Mathematics;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
@@ -15,20 +14,12 @@ 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
partial class MarkdownView : Decorator
{
public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(nameof(Source));
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
// Immagini (ADR-0006): solo file locali, entro questi limiti
const long MaxImageBytes = 20L * 1024 * 1024;
const int MaxDecodeWidth = 1600;
const double MaxInlineImageWidth = 480;
static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"];
static readonly FontFamily Mono = new("Cascadia Code, Consolas, DejaVu Sans Mono, Liberation Mono, monospace");
/// <summary>Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero.</summary>
internal int PendingImages { get; private set; }
public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); }
public MarkdownView()
@@ -43,7 +34,14 @@ class MarkdownView : Decorator
if (change.Property == SourceProperty) Rebuild();
}
void Rebuild() => Child = string.IsNullOrEmpty(Source) ? null : RenderBlocks(Markdig.Markdown.Parse(Source, Pipeline), 10);
string parsed = "";
void Rebuild()
{
// fine riga uniformi: gli intervalli di Markdig (Span) devono valere per il testo che si legge qui (ADR-0008)
parsed = Source?.ReplaceLineEndings("\n") ?? "";
Child = parsed.Length == 0 ? null : RenderBlocks(Markdig.Markdown.Parse(parsed, Pipeline), 10);
}
IBrush Brush(string key) => this.TryFindResource(key, ActualThemeVariant, out var v) && v is IBrush b ? b : Brushes.Gray;
@@ -58,6 +56,7 @@ class MarkdownView : Decorator
{
HeadingBlock h => Text(h.Inline, bold: true, size: h.Level switch { 1 => 24, 2 => 20, 3 => 18, 4 => 16, _ => 15.5 }),
ParagraphBlock p => ImageParagraph(p) ?? Text(p.Inline),
MathBlock m => FormulaBlock(BlockSource(m)),
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),
@@ -132,94 +131,6 @@ class MarkdownView : Decorator
return new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), CornerRadius = new(6), ClipToBounds = true, Child = grid };
}
static string AltText(LinkInline img) => string.Concat(img.OfType<LiteralInline>().Select(l => l.Content.ToString()));
/// <summary>Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi.</summary>
internal static string? ResolveImagePath(string? url)
{
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);
}
/// <summary>Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica.</summary>
static bool IsImageCandidate(string? url)
{
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;
}
catch { return false; }
}
/// <summary>Decodifica l'immagine (chiamata fuori dal thread della GUI); nullo se illeggibile, mai eccezioni.</summary>
static Bitmap? DecodeImage(string? url)
{
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;
bmp.Dispose();
fs.Position = 0;
return Bitmap.DecodeToWidth(fs, MaxDecodeWidth);
}
catch { return null; }
}
// Segnaposto `[alt]` che diventa l'immagine a decodifica finita; se la decodifica fallisce resta il testo.
Control ImageHost(string? url, 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);
return host;
}
async Task LoadInto(Decorator host, string? url, string alt, double maxWidth)
{
try
{
if (await Task.Run(() => DecodeImage(url)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth);
}
finally { PendingImages--; }
}
static Image ImageOf(Bitmap bmp, string alt, double maxWidth)
{
var img = new Image
{
Source = bmp, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly,
MaxWidth = Math.Min(maxWidth, bmp.Size.Width), HorizontalAlignment = HorizontalAlignment.Left,
};
AutomationProperties.SetName(img, alt);
ToolTip.SetTip(img, alt);
return img;
}
// Paragrafo fatto solo di immagini: blocchi alla larghezza della colonna. Nullo se non è così o nessuna è plausibile.
Control? ImageParagraph(ParagraphBlock p)
{
if (p.Inline is null) return null;
var images = new List<LinkInline>();
foreach (var node in p.Inline)
{
if (node is LinkInline { IsImage: true } img) images.Add(img);
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 panel = new StackPanel { Spacing = 8 };
foreach (var image in images)
panel.Children.Add(IsImageCandidate(image.Url) ? ImageHost(image.Url, AltText(image), double.PositiveInfinity)
: new SelectableTextBlock { Text = "[" + AltText(image) + "]", TextWrapping = TextWrapping.Wrap });
return panel;
}
// 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)
{
@@ -243,6 +154,16 @@ class MarkdownView : Decorator
switch (node)
{
case LiteralInline lit: Add(target, lit.Content.ToString(), st, ref pos); break;
case MathInline math:
var latex = math.Content.ToString();
if (IsDollarText(parsed, math)) // es. «10 $ e 20 $»: non è una formula
Add(target, parsed.Substring(math.Span.Start, math.Span.End - math.Span.Start + 1), st, ref pos);
else
{
target.Add(new InlineUIContainer(FormulaInline(latex)));
pos += 1; // un controllo incorporato occupa una posizione nel testo
}
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") });
@@ -254,9 +175,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);
+1
View File
@@ -25,6 +25,7 @@
<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="Sylinko.CSharpMath.Avalonia" Version="12.0.0" />
</ItemGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Doppio di test di `claude` (vedi docs/TESTING.md → FakeClaude): l'eseguibile si chiama `claude` per poterlo mettere nel PATH -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>claude</AssemblyName>
<RootNamespace>FakeClaude</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+15
View File
@@ -0,0 +1,15 @@
using System.Text.Json;
// Configurazione e formato del registro: docs/TESTING.md → FakeClaude.
var stdin = Console.In.ReadToEnd(); // resta in attesa finché il chiamante non chiude stdin
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_LOG") is { Length: > 0 } log)
File.AppendAllText(log, JsonSerializer.Serialize(new { args, cwd = Directory.GetCurrentDirectory(), stdin }) + "\n");
if (int.TryParse(Environment.GetEnvironmentVariable("FAKE_CLAUDE_DELAY_MS"), out var delay) && delay > 0)
Thread.Sleep(delay);
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_OUT") is { } o) Console.Out.Write(o);
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_ERR") is { } e) Console.Error.Write(e);
return int.TryParse(Environment.GetEnvironmentVariable("FAKE_CLAUDE_EXIT"), out var code) ? code : 0;
@@ -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: <radice>/catalogo.json, <radice>/figure/<file>, exe in <radice>/query/
string Wiki(string name, params string[] files)
{
var root = Path.Combine(dir, name);
Directory.CreateDirectory(Path.Combine(root, "figure"));
Directory.CreateDirectory(Path.Combine(root, "query"));
foreach (var f in files) File.WriteAllBytes(Path.Combine(root, "figure", f), PngFactory.Create(24, 12));
File.WriteAllText(Path.Combine(root, "catalogo.json"), "{\"figures\":[]}");
return root;
}
[AvaloniaFact]
public void Immagine_percorso_relativo_alla_radice()
{
var root = Wiki("wiki-a", "fig1.png");
var (_, view) = Show("![Figura 1](figure/fig1.png)", baseDir: Path.Combine(root, "query"));
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, "figure", "x.png"), PngFactory.Create(30, 30));
var exeDir = Path.Combine(root, "query");
Directory.CreateDirectory(Path.Combine(exeDir, "figure"));
File.WriteAllBytes(Path.Combine(exeDir, "figure", "x.png"), PngFactory.Create(10, 10));
var (_, view) = Show("![x](figure/x.png)", baseDir: exeDir);
var bmp = Assert.IsAssignableFrom<Bitmap>(Assert.Single(Images(view)).Source);
Assert.Equal(new PixelSize(10, 10), bmp.PixelSize); // vince la cartella più vicina all'exe
var (_, nowhere) = Show("![y](figure/non-esiste.png)", baseDir: exeDir);
Assert.Empty(Images(nowhere));
Assert.Contains("[y]", Text(nowhere));
}
[AvaloniaFact]
public void Immagine_risposta_con_didascalia()
{
var root = Wiki("wiki-c", "schema-a.png");
var risposta = """
Lo schema è in Figura 1.
![Figura 1](figure/schema-a.png)
Figura 1 - schema generale del sistema · §2.1 r.40 · [[fig-001]]
""";
var (_, view) = Show(risposta, baseDir: Path.Combine(root, "query"));
Assert.Single(Images(view));
var text = Text(view);
Assert.Contains("Figura 1 - schema generale", text);
Assert.Contains("[[fig-001]]", text); // il link alla nota resta testo: l'app non legge il catalogo JSON
}
}
@@ -0,0 +1,138 @@
using Avalonia;
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Headless.XUnit;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using CSharpMath.Avalonia;
using AgentWikiUi.Gui;
namespace AgentWikiUi.Tests;
/// <summary>Formule LaTeX nel markdown (ADR-0008): matrice MD-31…MD-38 in docs/TESTING.md.</summary>
[Trait("Category", "Gui")]
public sealed class MarkdownMathTests
{
static (Window Window, MarkdownView View) Show(string markdown, double width = 600)
{
var view = new MarkdownView();
var window = new Window { Width = width, Height = 800, Content = view };
window.Show();
view.Source = markdown;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
return (window, view);
}
static List<MathView> Formulas(Visual v) => v.GetVisualDescendants().OfType<MathView>().ToList();
static string Text(Visual v) => string.Concat(v.GetVisualDescendants().OfType<TextBlock>()
.Select(t => t.Inlines is { Count: > 0 } ? string.Concat(t.Inlines.OfType<Run>().Select(r => r.Text)) : t.Text));
[AvaloniaFact]
public void Formula_inline()
{
var (_, view) = Show("Il gap è $E_g = E_{LUMO} - E_{HOMO}$ in eV.");
var f = Assert.Single(Formulas(view));
Assert.Equal("E_g = E_{LUMO} - E_{HOMO}", AutomationProperties.GetName(f));
Assert.True(string.IsNullOrEmpty(f.ErrorMessage));
Assert.True(f.Bounds.Width > 0 && f.Bounds.Height > 0);
var text = Text(view);
Assert.Contains("Il gap è", text);
Assert.Contains("in eV.", text);
Assert.DoesNotContain("$", text);
}
[AvaloniaFact]
public void Formula_a_blocco()
{
var (_, view) = Show("Prima.\n\n$$\n\\frac{a}{b} = c\n$$\n\nDopo.");
var f = Assert.Single(Formulas(view));
Assert.Equal("\\frac{a}{b} = c", AutomationProperties.GetName(f));
Assert.True(string.IsNullOrEmpty(f.ErrorMessage));
Assert.True(f.Bounds.Height > 0);
Assert.DoesNotContain("\\frac", Text(view)); // non è un blocco di codice col sorgente
Assert.Contains("Prima.", Text(view));
Assert.Contains("Dopo.", Text(view));
}
[AvaloniaTheory]
[InlineData("Testo $\\frac{a$ fine")]
[InlineData("$$\n\\unknowncommand{x}\n$$")]
[InlineData("Testo $\\begin{matrix$ fine")]
public void Formula_non_valida_ripiego(string markdown)
{
var (_, view) = Show(markdown);
Assert.Empty(Formulas(view));
Assert.Contains("$", Text(view)); // il sorgente resta visibile come testo
}
[AvaloniaTheory]
[InlineData("\\frac{a}{b}")]
[InlineData("x^2 + y_i")]
[InlineData("\\mathbf{A} \\times \\mathbf{B}")]
[InlineData("\\overline{x}")]
[InlineData("\\left( \\frac{1}{2} \\right)")]
[InlineData("\\lambda_{max} \\approx \\pi \\cdot \\tau")]
[InlineData("\\sum_{i=1}^{n} x_i")]
[InlineData("\\text{Mn} = \\chi \\varepsilon")]
public void Costrutti_latex_comuni(string latex)
{
var (_, view) = Show($"Prima $${latex}$$ dopo".Replace("$$", "$"));
var f = Assert.Single(Formulas(view));
Assert.Equal(latex, AutomationProperties.GetName(f));
Assert.True(string.IsNullOrEmpty(f.ErrorMessage), f.ErrorMessage);
Assert.True(f.Bounds.Width > 0);
}
[AvaloniaFact]
public void Formula_cambio_tema()
{
var (window, view) = Show("Gap $E_g$ e\n\n$$\\frac{a}{b}$$");
Assert.Equal(2, Formulas(view).Count);
window.RequestedThemeVariant = ThemeVariant.Dark;
Dispatcher.UIThread.RunJobs();
Assert.Equal(2, Formulas(view).Count);
window.RequestedThemeVariant = ThemeVariant.Light;
Dispatcher.UIThread.RunJobs();
Assert.Equal(2, Formulas(view).Count);
}
[AvaloniaFact]
public void Formula_in_contesti_annidati()
{
var markdown = """
Testo $a_1$ e un [link](https://example.com).
[vai a $b^2$](https://example.com)
- voce $c_3$
| x | y |
|---|---|
| $d_4$ | z |
""";
var (_, view) = Show(markdown);
Assert.Equal(4, Formulas(view).Count);
Assert.Contains("link", Text(view));
Assert.Contains("vai a", Text(view));
}
[AvaloniaFact]
public void Formula_nome_accessibile()
{
var (_, view) = Show("Gap $E_g$.");
var f = Assert.Single(Formulas(view));
Assert.Equal("E_g", AutomationProperties.GetName(f));
}
[AvaloniaFact]
public void Dollari_isolati_restano_testo()
{
var (_, view) = Show("Costa 10 $ e poi 20 $ in totale.");
Assert.Empty(Formulas(view));
Assert.Contains("10 $ e poi 20 $", Text(view));
}
}
@@ -0,0 +1,89 @@
using System.Diagnostics;
using System.Text.Json;
namespace AgentWikiUi.Tests;
/// <summary>Il doppio di test di <c>claude</c> (docs/TESTING.md → FakeClaude, FK-01…FK-05).</summary>
[Trait("Category", "Process")]
public sealed class FakeClaudeTests : IDisposable
{
readonly string dir = Directory.CreateTempSubdirectory("awui-fake-").FullName;
public void Dispose() => Directory.Delete(dir, true);
string LogPath => Path.Combine(dir, "log.jsonl");
Process Start(IEnumerable<string> args, Dictionary<string, string>? env = null, string? cwd = null)
{
var psi = new ProcessStartInfo(FakeClaudePath.Exe)
{
UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true,
WorkingDirectory = cwd ?? dir,
};
foreach (var a in args) psi.ArgumentList.Add(a);
psi.Environment["FAKE_CLAUDE_LOG"] = LogPath;
foreach (var (k, v) in env ?? []) psi.Environment[k] = v;
return Process.Start(psi)!;
}
[Fact]
public void Registra_argomenti_cwd_e_stdin()
{
var cwd = Directory.CreateDirectory(Path.Combine(dir, "lavoro")).FullName;
using var p = Start(["-p", "dì \"ciao\" a tutti", "--model", "m"], cwd: cwd);
p.StandardInput.Write("dati da stdin");
p.StandardInput.Close();
Assert.True(p.WaitForExit(10_000));
using var line = JsonDocument.Parse(File.ReadAllLines(LogPath).Single());
var r = line.RootElement;
Assert.Equal(["-p", "dì \"ciao\" a tutti", "--model", "m"], r.GetProperty("args").EnumerateArray().Select(a => a.GetString()));
Assert.Equal(cwd, r.GetProperty("cwd").GetString());
Assert.Equal("dati da stdin", r.GetProperty("stdin").GetString());
}
[Fact]
public void Output_e_stderr_configurati()
{
using var p = Start([], new() { ["FAKE_CLAUDE_OUT"] = "risposta", ["FAKE_CLAUDE_ERR"] = "avviso" });
p.StandardInput.Close();
Assert.Equal("risposta", p.StandardOutput.ReadToEnd());
Assert.Equal("avviso", p.StandardError.ReadToEnd());
Assert.True(p.WaitForExit(10_000));
}
[Theory]
[InlineData(null, 0)]
[InlineData("3", 3)]
[InlineData("127", 127)]
public void ExitCode_configurato(string? configured, int expected)
{
using var p = Start([], configured is null ? null : new() { ["FAKE_CLAUDE_EXIT"] = configured });
p.StandardInput.Close();
Assert.True(p.WaitForExit(10_000));
Assert.Equal(expected, p.ExitCode);
}
[Fact]
public void Ritardo_configurato()
{
using var p = Start([], new() { ["FAKE_CLAUDE_DELAY_MS"] = "600" });
var sw = Stopwatch.StartNew();
p.StandardInput.Close();
Assert.True(p.WaitForExit(10_000));
Assert.True(sw.ElapsedMilliseconds >= 500, $"terminato dopo {sw.ElapsedMilliseconds} ms");
}
[Fact]
public void Attende_la_chiusura_di_stdin()
{
using var p = Start([]);
try
{
Assert.False(p.WaitForExit(700), "il processo è terminato con stdin ancora aperto");
p.StandardInput.Close();
Assert.True(p.WaitForExit(10_000));
}
finally { if (!p.HasExited) p.Kill(); }
}
}
@@ -0,0 +1,17 @@
namespace AgentWikiUi.Tests;
/// <summary>Percorso del <c>claude</c> finto compilato (tests/FakeClaude), nella stessa configurazione dei test.</summary>
static class FakeClaudePath
{
public static string Exe
{
get
{
// …/tests/agent-wiki-ui.Tests/bin/<config>/net10.0/
var tfm = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar));
var config = tfm.Parent!;
var testsRoot = config.Parent!.Parent!.Parent!;
return Path.Combine(testsRoot.FullName, "FakeClaude", "bin", config.Name, tfm.Name, OperatingSystem.IsWindows() ? "claude.exe" : "claude");
}
}
}
@@ -21,6 +21,8 @@
<ItemGroup>
<ProjectReference Include="../../src/agent-wiki-ui/agent-wiki-ui.csproj" />
<!-- solo compilato (non referenziato): i test avviano l'eseguibile -->
<ProjectReference Include="../FakeClaude/FakeClaude.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>