Merge di main in benedetta: motore Antigravity sulla struttura attuale
Conflitti risolti in AGENT.md e README.md: percorsi src/agent-wiki-ui e descrizione del renderer da main, AgyEngine e l'esempio --engine agy da benedetta. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dc7dbbd3bb
41 files changed
+1135
-40
No files matched your search
@@ -14,24 +14,24 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md
|
||||
|
||||
## Comandi
|
||||
|
||||
- Build: `dotnet build` (agent-wiki-ui.csproj, `net10.0`, Avalonia)
|
||||
- GUI: `dotnet run`; CLI: `dotnet run -- "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 -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64`
|
||||
- Test: non esistono ancora (pianificati, Fase 6: `dotnet test`; il piano è in docs/TESTING.md). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo.
|
||||
- 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; `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
|
||||
|
||||
Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa del codice.
|
||||
|
||||
- [Program.cs](Program.cs): top-level statements. Con argomenti è la CLI (`EngineRunner.RunInherited`, motore scelto con `--engine <id>` come primo argomento, default Claude Code); senza avvia Avalonia (`App`).
|
||||
- [Core/Engine.cs](Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, `AgyEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo).
|
||||
- [Core/EngineRunner.cs](Core/EngineRunner.cs): **unico punto di avvio dei motori** (cartella dell'exe, stdin chiuso, `ArgumentList`): CLI con output ereditato, GUI con output catturato e interpretato da `Engine.Parse`, e `RunToolAsync` per i comandi di servizio (`--version`, `update`) con tempo massimo.
|
||||
- [Core/EngineUpdater.cs](Core/EngineUpdater.cs): controllo delle versioni in sola lettura (`CheckAsync`, `IsNewer`) e aggiornamento di un CLI con il suo `update` ufficiale (ADR-0004): versione prima/dopo, esito leggibile, metodo di installazione dedotto dal percorso reale e comando manuale da suggerire (l'app lo mostra, non lo esegue).
|
||||
- [Core/ChatStore.cs](Core/ChatStore.cs): cronologia, un JSON per chat in `<LocalApplicationData>/<nome-app>/chats/<guid>.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code).
|
||||
- [Core/AppInfo.cs](Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente).
|
||||
- [Gui/MainViewModel.cs](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](Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](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](Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`).
|
||||
- [Program.cs](src/agent-wiki-ui/Program.cs): top-level statements. Con argomenti è la CLI (`EngineRunner.RunInherited`, motore scelto con `--engine <id>` come primo argomento, default Claude Code); senza avvia Avalonia (`App`).
|
||||
- [Core/Engine.cs](src/agent-wiki-ui/Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, `AgyEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo).
|
||||
- [Core/EngineRunner.cs](src/agent-wiki-ui/Core/EngineRunner.cs): **unico punto di avvio dei motori** (cartella dell'exe, stdin chiuso, `ArgumentList`): CLI con output ereditato, GUI con output catturato e interpretato da `Engine.Parse`, e `RunToolAsync` per i comandi di servizio (`--version`, `update`) con tempo massimo.
|
||||
- [Core/EngineUpdater.cs](src/agent-wiki-ui/Core/EngineUpdater.cs): controllo delle versioni in sola lettura (`CheckAsync`, `IsNewer`) e aggiornamento di un CLI con il suo `update` ufficiale (ADR-0004): versione prima/dopo, esito leggibile, metodo di installazione dedotto dal percorso reale e comando manuale da suggerire (l'app lo mostra, non lo esegue).
|
||||
- [Core/ChatStore.cs](src/agent-wiki-ui/Core/ChatStore.cs): cronologia, un JSON per chat in `<LocalApplicationData>/<nome-app>/chats/<guid>.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code).
|
||||
- [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`); 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
|
||||
|
||||
@@ -11,14 +11,14 @@ Eseguibile C# (.NET 10, Windows e Linux) che lancia Claude Code (`claude -p`), C
|
||||
|
||||
GUI (nessun argomento): finestra di chat in stile claude.ai, con la conversazione in markdown in alto e il campo messaggio in basso (`Invio` invia, `Maiusc+Invio` va a capo, "Nuova chat" azzera il contesto). Il riquadro **Impostazioni** (in alto a destra) permette di scegliere la **CLI** (Claude Code, Codex o Antigravity), il **modello** e l'**effort** di ragionamento. All'avvio, e a ogni cambio di CLI, sono scelti il modello più piccolo (Haiku 5.5 per Claude Code, GPT-5.6 Luna per Codex, Gemini 3.0 Flash Low per Antigravity) ed effort `low`, per risposte rapide e economiche; "Predefinito" = nessun flag, vale la config del motore. Le chat si salvano su disco e l'ultima si riapre all'avvio. Sempre nel riquadro, la sezione **Aggiornamenti** dice se Claude Code, Codex e Antigravity hanno una versione nuova (pulsante evidenziato) e li aggiorna uno alla volta.
|
||||
```
|
||||
dotnet run
|
||||
dotnet run --project src/agent-wiki-ui
|
||||
```
|
||||
|
||||
CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello del motore (127 se non è nel PATH). Di default usa Claude Code; `--engine codex` usa Codex; `--engine agy` usa Antigravity.
|
||||
```
|
||||
dotnet run -- "spiega questo progetto"
|
||||
dotnet run -- --engine codex "spiega questo progetto"
|
||||
dotnet run -- --engine agy "spiega questo progetto"
|
||||
dotnet run --project src/agent-wiki-ui -- "spiega questo progetto"
|
||||
dotnet run --project src/agent-wiki-ui -- --engine codex "spiega questo progetto"
|
||||
dotnet run --project src/agent-wiki-ui -- --engine agy "spiega questo progetto"
|
||||
```
|
||||
|
||||
## Creare gli eseguibili (Windows e Linux)
|
||||
@@ -26,7 +26,7 @@ Ogni eseguibile è un **unico file** che include il runtime .NET: chi lo usa non
|
||||
|
||||
### 1. Installare il .NET SDK 10
|
||||
- **Windows** (PowerShell o Prompt dei comandi): `winget install Microsoft.DotNet.SDK.10`, oppure il programma di installazione da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download).
|
||||
- **Linux / WSL / macOS**: pacchetto della distribuzione o script ufficiale da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download).
|
||||
- **Linux / WSL / macOS**: pacchetto della distribuzione o script ufficiale da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). Funziona anche su ARM64 (Raspberry Pi): `curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh && bash /tmp/dotnet-install.sh --channel 10.0`, poi `export DOTNET_ROOT=$HOME/.dotnet PATH=$PATH:$HOME/.dotnet` (da mettere in `~/.bashrc`).
|
||||
- Controllo: `dotnet --version` deve stampare `10.x`.
|
||||
|
||||
### 2. Creare gli eseguibili
|
||||
@@ -46,11 +46,11 @@ Risultato (circa 47 MB per file), in `dist/`, ignorata da git:
|
||||
| Windows x64 | `dist/win-x64/agent-wiki-ui.exe` |
|
||||
| Linux x64 | `dist/linux-x64/agent-wiki-ui` |
|
||||
|
||||
Altri identificatori (per esempio `win-arm64`, `linux-arm64`) si possono provare allo stesso modo, ma non sono verificati. Lo script è solo una comodità: il comando sotto fa la stessa cosa.
|
||||
Altri identificatori (per esempio `win-arm64`, `linux-arm64`) si passano allo script allo stesso modo (`scripts/publish.sh linux-arm64`, risultato in `dist/linux-arm64/`), ma non sono verificati. Su un Raspberry Pi servono anche i motori: per Claude Code vedi [docs/INSTALL.md](docs/INSTALL.md). Lo script è solo una comodità: il comando sotto fa la stessa cosa.
|
||||
|
||||
**Senza script** (identico su qualsiasi sistema; sostituisci `<rid>` con `win-x64` o `linux-x64`):
|
||||
```
|
||||
dotnet publish -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o dist/<rid>
|
||||
dotnet publish src/agent-wiki-ui -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o dist/<rid>
|
||||
```
|
||||
Senza `-o` il risultato è in `bin/Release/net10.0/<rid>/publish/`.
|
||||
|
||||
@@ -62,6 +62,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`, `agy`) 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 `` 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, Codex CLI e Antigravity CLI, già installati da terminale: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/agent-wiki-ui/agent-wiki-ui.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj" />
|
||||
<Project Path="tests/FakeClaude/FakeClaude.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
+12
-1
@@ -8,7 +8,7 @@ Il motore va installato **nello stesso ambiente in cui gira l'app**:
|
||||
| Dove avvii l'app | Dove installare il motore |
|
||||
|---|---|
|
||||
| Linux nativo | nel terminale Linux |
|
||||
| WSL (anche con `dotnet run`) | nel terminale della distribuzione WSL |
|
||||
| WSL (anche con `dotnet run --project src/agent-wiki-ui`) | nel terminale della distribuzione WSL |
|
||||
| Windows (`.exe`) | in Windows (PowerShell), non in WSL |
|
||||
|
||||
Dentro WSL il PATH di Windows è visibile (compare `/mnt/c/…`): se in WSL non c'è un `claude`, `codex` o `agy`, il comando può risolvere nella versione Windows. Controllalo con `command -v claude`, `command -v codex` e `command -v agy`.
|
||||
@@ -31,6 +31,17 @@ Dentro WSL il PATH di Windows è visibile (compare `/mnt/c/…`): se in WSL non
|
||||
|
||||
L'installazione nativa non mostra avanzamento durante il download. A fine installazione apri un **nuovo** terminale.
|
||||
|
||||
**Da zero su Linux, anche ARM64 (per esempio Raspberry Pi 5 con Raspberry Pi OS a 64 bit).** Il metodo nativo riconosce da solo l'architettura (`uname -m` deve dare `aarch64` o `x86_64`):
|
||||
```
|
||||
sudo apt install -y curl # se manca
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
# apri un nuovo terminale, poi:
|
||||
command -v claude # atteso: ~/.local/bin/claude
|
||||
claude --version
|
||||
claude # primo avvio: accesso nel browser
|
||||
```
|
||||
Su un Raspberry senza schermo l'accesso nel browser si fa da un altro dispositivo, seguendo l'indirizzo che `claude` stampa nel terminale (per esempio via SSH); in alternativa usa una API key o un provider cloud, vedi "Accesso". Se `~/.local/bin` non è nel PATH aggiungi `export PATH="$HOME/.local/bin:$PATH"` a `~/.bashrc`. Con la sola architettura ARM64 e Debian 10+ i requisiti sono soddisfatti; il metodo npm è l'alternativa se lo script nativo non funziona. Non provato su un Raspberry reale: le istruzioni vengono dalla guida ufficiale.
|
||||
|
||||
**Accesso**: esegui `claude` e segui le istruzioni nel browser. Serve un account Pro, Max, Team, Enterprise o Console (il piano gratuito di claude.ai non include Claude Code). In alternativa Amazon Bedrock, Google Cloud o Microsoft Foundry.
|
||||
|
||||
**Verifica**
|
||||
|
||||
+4
-4
@@ -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. Il rendering è un renderer proprio ([MarkdownView.cs](../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 `` 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, Antigravity; un motore non installato è mostrato "non installato" e non è selezionabile; disabilitato durante l'attesa), poi **Modello** (Predefinito, catalogo del motore, "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; Antigravity: Gemini 3.0 Flash Low) 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.
|
||||
@@ -35,7 +35,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile
|
||||
- Finestra orizzontale con proporzioni 16:10: larga il 75% dell'area utile dello schermo (max 1440 px logici), altezza di conseguenza (max 90% dell'area utile), centrata; minimo 900x600. Su un monitor Full HD 1920x1080 risulta circa 1440x900.
|
||||
|
||||
### Design (UI/UX)
|
||||
Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). I token vivono come risorse di tema in [Gui/App.axaml](../Gui/App.axaml).
|
||||
Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). I token vivono come risorse di tema in [Gui/App.axaml](../src/agent-wiki-ui/Gui/App.axaml).
|
||||
- **Tema**: segue il sistema (chiaro/scuro) tramite `RequestedThemeVariant="Default"`.
|
||||
- **Token** (chiaro / scuro): sfondo `#FFFFFF` / `#0F172A`, superficie composer `#FFFFFF` / `#1E293B`, testo `#0F172A` / `#F8FAFC`, testo secondario `#475569` / `#94A3B8`, bordi `#CBD5E1` / `#334155`, accento `#15803D` / `#22C55E`, errore `#B91C1C` / `#F87171`. Contrasto testo ≥ 4.5:1.
|
||||
- **Tipografia**: font di sistema (Segoe UI Variable/Segoe UI su Windows; Inter, Ubuntu, Cantarell, Noto Sans, DejaVu Sans su Linux; corpo 15.5, titoli fino a 24), Cascadia Code/Consolas/DejaVu Sans Mono per il codice. Nessun font incorporato (funziona offline).
|
||||
@@ -46,11 +46,11 @@ 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.
|
||||
- L'avvio dei motori è in un solo punto ([Core/EngineRunner.cs](../Core/EngineRunner.cs)), usato da CLI e GUI; ogni motore descrive argomenti e parser in [Core/Engine.cs](../Core/Engine.cs).
|
||||
- L'avvio dei motori è in un solo punto ([Core/EngineRunner.cs](../src/agent-wiki-ui/Core/EngineRunner.cs)), usato da CLI e GUI; ogni motore descrive argomenti e parser in [Core/Engine.cs](../src/agent-wiki-ui/Core/Engine.cs).
|
||||
|
||||
## Fuori scope (MVP)
|
||||
Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code, Codex e Antigravity ([ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, supporto macOS. I test automatici sono pianificati: [TESTING.md](TESTING.md).
|
||||
|
||||
@@ -42,6 +42,9 @@ Da [MVP.md](MVP.md). Restano non spuntati finché non hanno test automatici (reg
|
||||
| [0003](adr/0003-strategia-di-test.md) Strategia di test | Proposta |
|
||||
| [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.
|
||||
|
||||
+4
-1
@@ -17,7 +17,7 @@ Indice e regole. Ogni informazione vive in **un solo documento**; gli altri la r
|
||||
Il nome è **`agent-wiki-ui`** (prima `LlmRun`). Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o `<nome-app>`.
|
||||
|
||||
Se il nome cambia di nuovo, aggiornare solo questi punti:
|
||||
- [ ] `AssemblyName` in [../agent-wiki-ui.csproj](../agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `<nome-app>.exe` / `<nome-app>`
|
||||
- [ ] `AssemblyName` in [../src/agent-wiki-ui/agent-wiki-ui.csproj](../src/agent-wiki-ui/agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `<nome-app>.exe` / `<nome-app>`
|
||||
- [ ] `RootNamespace` e namespace C# (`AgentWikiUi.*`, anche in `x:Class` e `xmlns` dei file `.axaml`): il nome del namespace non può avere trattini
|
||||
- [ ] **Cartella dati** `<LocalApplicationData>/<nome-app>/chats`: cambiando nome le chat salvate non si trovano più. `ChatStore` sposta una vecchia cartella (`LlmRun`) in quella nuova al primo avvio; per un nuovo cambio aggiungere il nome precedente all'elenco `LegacyNames`
|
||||
- [ ] Titolo di [../README.md](../README.md) e riga iniziale di [../AGENT.md](../AGENT.md) (nome del csproj nei comandi)
|
||||
@@ -52,3 +52,6 @@ Non si scrive nulla nei puntatori: ogni modifica va in AGENT.md. Per un altro st
|
||||
| [0003](adr/0003-strategia-di-test.md) | Strategia di test e regola "nuova funzione = nuovi test" | Proposta |
|
||||
| [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
-2
@@ -22,6 +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-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 +68,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)
|
||||
|
||||
+49
-6
@@ -21,20 +21,34 @@ 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`)
|
||||
- [ ] `IEngineRunner` dietro `Core/EngineRunner.cs`, iniettato nel `MainViewModel`
|
||||
- [x] `EngineUpdater.CheckAsync` accetta un `HttpClient`: i test usano un gestore finto (rete assente, risposta non valida, versioni di prova)
|
||||
- [ ] `InternalsVisibleTo` verso il progetto di test
|
||||
- [ ] Spostamento in `src/` e `tests/` con soluzione unica
|
||||
- [x] `InternalsVisibleTo` verso il progetto di test
|
||||
- [x] Spostamento in `src/agent-wiki-ui/` e `tests/` con soluzione unica `agent-wiki-ui.slnx`
|
||||
|
||||
## Soglie di copertura (la build fallisce sotto soglia)
|
||||
| Area | Righe | Rami |
|
||||
@@ -181,7 +195,27 @@ Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`;
|
||||
| MD-11 | Link: colore d'accento e sottolineatura; il clic apre solo `http(s)` | R | `Link_http_https` | [ ] |
|
||||
| MD-12 | Link: nessuna apertura se c'è una selezione in corso; cursore a mano sopra il link | R | `Link_con_selezione` | [ ] |
|
||||
| MD-13 | Posizioni dei link corrette con enfasi, codice inline e a capo prima del link | R | `Intervalli_link` | [ ] |
|
||||
| MD-14 | Immagini: si mostra il testo alternativo, non si scarica nulla | R | `Immagini_come_testo` | [ ] |
|
||||
| MD-14 | Immagini con URL `http(s)` o altro schema: si mostra il testo alternativo, nessuna richiesta di rete | R | `Immagini_remote_come_testo` | [x] |
|
||||
| MD-19 | Immagine con percorso assoluto di un file png valido ⇒ controllo immagine con le dimensioni attese (ADR-0006) | R | `Immagine_percorso_assoluto` | [x] |
|
||||
| MD-20 | Percorso relativo risolto rispetto a `AppContext.BaseDirectory`; anche URI `file://` | R | `Immagine_percorso_relativo_e_file_uri` | [x] |
|
||||
| MD-21 | File mancante, illeggibile, corrotto o con estensione non ammessa ⇒ testo alternativo, nessuna eccezione | R | `Immagine_non_valida_ripiego` | [x] |
|
||||
| MD-22 | File oltre il limite di 20 MB ⇒ testo alternativo, file non decodificato | R | `Immagine_oltre_limite` | [x] |
|
||||
| MD-23 | Immagine più larga della colonna ⇒ ridotta in larghezza mantenendo le proporzioni; mai ingrandita oltre l'originale | R | `Immagine_larghezza_massima` | [x] |
|
||||
| MD-24 | Il testo alternativo è nome accessibile e suggerimento dell'immagine | R | `Immagine_testo_alternativo_accessibile` | [x] |
|
||||
| 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` | [ ] |
|
||||
@@ -248,6 +282,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`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ADR-0006 — Immagini nel markdown: solo file locali
|
||||
|
||||
- **Stato**: Proposta
|
||||
- **Data**: 2026-10-15
|
||||
- **Collegato a**: [ADR-0001](0001-gui-multipiattaforma-avalonia.md) (GUI), [ADR-0003](0003-strategia-di-test.md) (test)
|
||||
|
||||
## Contesto
|
||||
Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/MarkdownView.cs)) trasforma `` nel solo testo alternativo `[alt]`: non carica nulla. Un motore (Claude Code, Codex) può però produrre o citare un'immagine su disco (un grafico, uno screenshot) e rispondere con ``. Se il percorso è noto e il file esiste, l'utente si aspetta di vederla nella chat. Oggi l'app contatta la rete solo per il controllo delle versioni ([AGENT.md](../../AGENT.md), "Vincoli non ovvi"); aprire le immagini remote cambierebbe questo vincolo.
|
||||
|
||||
## Criteri di decisione
|
||||
1. Mostrare l'immagine quando il percorso è locale e il file è leggibile.
|
||||
2. Nessun accesso alla rete oltre a quello già previsto.
|
||||
3. Nessun crash e nessun blocco della chat per file mancanti, enormi o corrotti: ripiego sul testo alternativo di oggi.
|
||||
4. Coerenza con il resto del renderer: colori dal tema, testo selezionabile, accessibilità.
|
||||
|
||||
## Opzioni considerate
|
||||
| Opzione | Esito | Motivo |
|
||||
|---|---|---|
|
||||
| Lasciare il testo `[alt]` | Scartata | Il percorso noto resta inutilizzato. |
|
||||
| **Solo percorsi locali** | **Scelta** | Soddisfa il caso d'uso, nessuna rete, sola lettura. |
|
||||
| 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
|
||||
- `` 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 «``, 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.
|
||||
- **Ripiego** sul testo `[alt]` (come oggi) quando: la destinazione è `http(s)` o altro schema, il file manca o non è leggibile, il formato non è ammesso, il file supera il limite, la decodifica fallisce. Nessun errore a schermo, nessuna eccezione.
|
||||
- **Caricamento in background**: al rendering un controllo di controllo rapido e sincrono (percorso, estensione, esistenza, dimensione) decide se l'immagine è plausibile; se no, compare subito `[alt]`. Se sì, compare `[alt]` come segnaposto e la decodifica avviene su un altro thread; a fine decodifica il segnaposto diventa l'immagine, se la decodifica fallisce resta `[alt]`. Così la GUI non si blocca con file grandi o dischi lenti.
|
||||
- Accessibilità: l'immagine espone `alt` come nome per le tecnologie assistive e come suggerimento al passaggio del mouse.
|
||||
- Nessuna azione al clic (non si apre il file). Il pulsante **Copia** della risposta continua a copiare il markdown originale.
|
||||
|
||||
## Conseguenze
|
||||
**Positive**: le immagini prodotte dai motori si vedono nella chat; nessuna richiesta di rete in più; ripiego sicuro.
|
||||
**Negative**: una risposta può far leggere e mostrare un file immagine qualsiasi dell'utente (sola lettura, solo a schermo, nessun invio altrove); le immagini remote restano non supportate e si vedono come testo.
|
||||
|
||||
## 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 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-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).
|
||||
+1
-1
@@ -8,7 +8,7 @@ Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
foreach ($rid in $Rids) {
|
||||
Write-Host "== $rid"
|
||||
dotnet publish agent-wiki-ui.csproj -c Release -r $rid --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
|
||||
dotnet publish src/agent-wiki-ui/agent-wiki-ui.csproj -c Release -r $rid --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
|
||||
if ($LASTEXITCODE -ne 0) { throw "publish fallito per $rid" }
|
||||
Get-ChildItem "dist/$rid" -Filter *.pdb -Recurse | Remove-Item # simboli delle librerie native: inutili a chi usa l'app
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ rids=("$@")
|
||||
|
||||
for rid in "${rids[@]}"; do
|
||||
echo "== $rid"
|
||||
dotnet publish agent-wiki-ui.csproj -c Release -r "$rid" --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
|
||||
dotnet publish src/agent-wiki-ui/agent-wiki-ui.csproj -c Release -r "$rid" --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
|
||||
find "dist/$rid" -name '*.pdb' -delete # simboli delle librerie native: inutili a chi usa l'app
|
||||
done
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Markdig;
|
||||
using Markdig.Extensions.Mathematics;
|
||||
using Markdig.Extensions.Tables;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
@@ -13,7 +14,7 @@ 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();
|
||||
@@ -33,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;
|
||||
|
||||
@@ -47,7 +55,8 @@ class MarkdownView : Decorator
|
||||
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),
|
||||
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),
|
||||
@@ -145,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") });
|
||||
@@ -156,7 +175,12 @@ class MarkdownView : Decorator
|
||||
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);
|
||||
if (ResolveImagePath(img.Url, BaseDirectory) is { } imgPath && IsImageCandidate(imgPath))
|
||||
{
|
||||
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);
|
||||
break;
|
||||
case LinkInline link:
|
||||
var start = pos;
|
||||
File renamed without changes.
File renamed without changes.
@@ -16,11 +16,16 @@
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="agent-wiki-ui.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<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="Sylinko.CSharpMath.Avalonia" Version="12.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -0,0 +1,265 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Automation;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using AgentWikiUi.Gui;
|
||||
|
||||
namespace AgentWikiUi.Tests;
|
||||
|
||||
/// <summary>Immagini nel markdown (ADR-0006): matrice MD-14 e MD-19…MD-26 in docs/TESTING.md.</summary>
|
||||
[Trait("Category", "Gui")]
|
||||
public sealed class MarkdownImageTests : IDisposable
|
||||
{
|
||||
readonly string dir = Directory.CreateTempSubdirectory("awui-img-").FullName;
|
||||
readonly List<string> inBase = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
foreach (var f in inBase) File.Delete(f);
|
||||
}
|
||||
|
||||
string Png(string name, int w, int h)
|
||||
{
|
||||
var path = Path.Combine(dir, name);
|
||||
File.WriteAllBytes(path, PngFactory.Create(w, h));
|
||||
return path;
|
||||
}
|
||||
|
||||
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;
|
||||
WaitForImages(view);
|
||||
return (window, view);
|
||||
}
|
||||
|
||||
// il caricamento è in background (ADR-0006): si fa girare la coda della GUI finché tutte le immagini sono decodificate
|
||||
static void WaitForImages(MarkdownView view)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (view.PendingImages > 0 && DateTime.UtcNow < deadline) { Dispatcher.UIThread.RunJobs(); Thread.Sleep(5); }
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
(TopLevel.GetTopLevel(view) as Window)?.UpdateLayout();
|
||||
Assert.Equal(0, view.PendingImages);
|
||||
}
|
||||
|
||||
static List<Image> Images(Visual v) => v.GetVisualDescendants().OfType<Image>().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));
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("https://example.com/a.png")]
|
||||
[InlineData("http://example.com/a.png")]
|
||||
[InlineData("ftp://example.com/a.png")]
|
||||
[InlineData("data:image/png;base64,AAAA")]
|
||||
public void Immagini_remote_come_testo(string url)
|
||||
{
|
||||
var (_, view) = Show($"Vedi  qui");
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[logo]", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_percorso_assoluto()
|
||||
{
|
||||
var path = Png("a.png", 40, 20);
|
||||
var (_, view) = Show($"");
|
||||
var img = Assert.Single(Images(view));
|
||||
var bmp = Assert.IsAssignableFrom<Bitmap>(img.Source);
|
||||
Assert.Equal(new PixelSize(40, 20), bmp.PixelSize);
|
||||
Assert.Equal(40, img.Bounds.Width, 0.5);
|
||||
Assert.DoesNotContain("[griglia]", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_percorso_relativo_e_file_uri()
|
||||
{
|
||||
var name = $"awui-{Guid.NewGuid():N}.png";
|
||||
var inBasePath = Path.Combine(AppContext.BaseDirectory, name);
|
||||
File.WriteAllBytes(inBasePath, PngFactory.Create(10, 10));
|
||||
inBase.Add(inBasePath);
|
||||
var (_, relative) = Show($"");
|
||||
Assert.Single(Images(relative));
|
||||
|
||||
var uri = new Uri(Png("b.png", 12, 12)).AbsoluteUri;
|
||||
var (_, fileUri) = Show($"");
|
||||
Assert.Single(Images(fileUri));
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("manca")]
|
||||
[InlineData("corrotto")]
|
||||
[InlineData("estensione")]
|
||||
public void Immagine_non_valida_ripiego(string caso)
|
||||
{
|
||||
var path = caso switch
|
||||
{
|
||||
"manca" => Path.Combine(dir, "non-esiste.png"),
|
||||
"corrotto" => Write("rotto.png", "questo non è un png"u8.ToArray()),
|
||||
_ => Write("dati.txt", PngFactory.Create(8, 8)),
|
||||
};
|
||||
var (_, view) = Show($"");
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[alt]", Text(view));
|
||||
}
|
||||
|
||||
string Write(string name, byte[] bytes)
|
||||
{
|
||||
var path = Path.Combine(dir, name);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_oltre_limite()
|
||||
{
|
||||
var path = Path.Combine(dir, "grande.png");
|
||||
using (var fs = File.Create(path)) { fs.Write(PngFactory.Create(8, 8)); fs.SetLength(20L * 1024 * 1024 + 1); }
|
||||
var (_, view) = Show($"");
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[enorme]", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_larghezza_massima()
|
||||
{
|
||||
var (_, wide) = Show($"})", width: 500);
|
||||
var img = Assert.Single(Images(wide));
|
||||
Assert.True(img.Bounds.Width <= 500, $"larghezza {img.Bounds.Width}");
|
||||
Assert.Equal(img.Bounds.Width / 20, img.Bounds.Height, 1); // proporzioni 20:1
|
||||
|
||||
var (_, small) = Show($"})", width: 500);
|
||||
Assert.Equal(50, Assert.Single(Images(small)).Bounds.Width, 0.5); // mai ingrandita
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_testo_alternativo_accessibile()
|
||||
{
|
||||
var (_, view) = Show($"})");
|
||||
var img = Assert.Single(Images(view));
|
||||
Assert.Equal("un grafico", AutomationProperties.GetName(img));
|
||||
Assert.Equal("un grafico", ToolTip.GetTip(img));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_in_contesti_annidati()
|
||||
{
|
||||
var p = Png("n.png", 10, 10);
|
||||
var markdown = $"""
|
||||
Testo  e ancora testo con [un link](https://example.com).
|
||||
|
||||
[](https://example.com)
|
||||
|
||||
- voce 
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
|  | x |
|
||||
""";
|
||||
var (_, view) = Show(markdown);
|
||||
Assert.Equal(4, Images(view).Count);
|
||||
Assert.Contains("un link", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_cambio_tema()
|
||||
{
|
||||
var (window, view) = Show($"})");
|
||||
window.RequestedThemeVariant = ThemeVariant.Dark;
|
||||
WaitForImages(view);
|
||||
Assert.Single(Images(view));
|
||||
window.RequestedThemeVariant = ThemeVariant.Light;
|
||||
WaitForImages(view);
|
||||
Assert.Single(Images(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_caricamento_in_background()
|
||||
{
|
||||
var path = Png("bg.png", 30, 30);
|
||||
var view = new MarkdownView();
|
||||
var window = new Window { Width = 500, Height = 800, Content = view };
|
||||
window.Show();
|
||||
view.Source = $"";
|
||||
// subito dopo il rendering: immagine in attesa e solo il segnaposto (il completamento passa dalla coda della GUI)
|
||||
Assert.Equal(1, view.PendingImages);
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[segnaposto]", Text(view));
|
||||
WaitForImages(view);
|
||||
Assert.Single(Images(view));
|
||||
Assert.DoesNotContain("[segnaposto]", Text(view));
|
||||
|
||||
// file corrotto: la decodifica fallisce e il segnaposto resta
|
||||
var rotto = Write("rotto2.png", "no"u8.ToArray());
|
||||
view.Source = $"";
|
||||
WaitForImages(view);
|
||||
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("", 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("", 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("", 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 - 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace AgentWikiUi.Tests;
|
||||
|
||||
/// <summary>Scrive PNG minimali (colore pieno) senza dipendere da Avalonia.</summary>
|
||||
static class PngFactory
|
||||
{
|
||||
public static byte[] Create(int width, int height)
|
||||
{
|
||||
var raw = new byte[height * (1 + width * 3)];
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++) raw[y * (1 + width * 3) + 1 + x * 3] = 200; // filtro 0 per riga, canale rosso
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
var ihdr = new byte[13];
|
||||
WriteInt(ihdr, 0, width); WriteInt(ihdr, 4, height);
|
||||
ihdr[8] = 8; ihdr[9] = 2; // 8 bit, RGB
|
||||
Chunk(ms, "IHDR", ihdr);
|
||||
using (var z = new MemoryStream())
|
||||
{
|
||||
using (var zs = new ZLibStream(z, CompressionLevel.Fastest, leaveOpen: true)) zs.Write(raw);
|
||||
Chunk(ms, "IDAT", z.ToArray());
|
||||
}
|
||||
Chunk(ms, "IEND", []);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
static void WriteInt(byte[] b, int at, int v) { b[at] = (byte)(v >> 24); b[at + 1] = (byte)(v >> 16); b[at + 2] = (byte)(v >> 8); b[at + 3] = (byte)v; }
|
||||
|
||||
static void Chunk(Stream s, string type, byte[] data)
|
||||
{
|
||||
var len = new byte[4]; WriteInt(len, 0, data.Length);
|
||||
s.Write(len);
|
||||
var body = new byte[4 + data.Length];
|
||||
for (var i = 0; i < 4; i++) body[i] = (byte)type[i];
|
||||
data.CopyTo(body, 4);
|
||||
s.Write(body);
|
||||
var crc = new byte[4]; WriteInt(crc, 0, (int)Crc32(body));
|
||||
s.Write(crc);
|
||||
}
|
||||
|
||||
static uint Crc32(byte[] data)
|
||||
{
|
||||
var crc = 0xFFFFFFFFu;
|
||||
foreach (var b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1;
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Themes.Fluent;
|
||||
using AgentWikiUi.Tests;
|
||||
|
||||
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
|
||||
|
||||
namespace AgentWikiUi.Tests;
|
||||
|
||||
public class TestApp : Application
|
||||
{
|
||||
public override void Initialize() => Styles.Add(new FluentTheme());
|
||||
}
|
||||
|
||||
public static class TestAppBuilder
|
||||
{
|
||||
// Skia reale (non il disegno finto): serve per decodificare davvero le immagini
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<TestApp>()
|
||||
.UseSkia()
|
||||
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>AgentWikiUi.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" Version="12.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<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>
|
||||
Reference in new issue
Block a user