feat(chat): markdown formattato nella chat + testo pulito per la voce (v0.5.2)

- src/markdown.{h,cpp}: convertitore minimale, due funzioni:
  * toRichText() -> HTML per QML Text.RichText: grassetti (**x**/__x__),
    corsivi (*x*/_x_ con confini di parola per non toccare snake_case),
    titoli (#), liste (-/1.), citazioni (>), codice (`x` e blocchi ```,
    in monospace), link [t](u) -> solo testo, barrato ~~x~~; escape di
    < & > contro injection nel rich text
  * toSpeech() -> testo piatto per il TTS: via recinti di codice, marcatori
    di titoli/liste/citazioni, enfasi, backtick; link/immagini -> testo;
    tabelle | a | b | -> "a, b"; righe vuote compattate
- ChatModel: nuovo ruolo contentRich (calcolato in C++, testabile);
  incluso nei dataChanged dello streaming
- ChatPage: le bolle dell'assistente usano contentRich con
  textFormat: Text.RichText; i messaggi utente restano testo semplice
- ApiClient::speak(): il testo passa da Markdown::toSpeech (niente più
  simboli di formattazione letti ad alta voce)
- core-test: 19 casi nuovi per il convertitore (tutti locali, senza device)

Verificato: build zero warning + unit test completi.
This commit is contained in:
2026-09-13 08:04:19 +02:00
parent 257f9a079e
commit 7428d42b92
13 changed files with 320 additions and 14 deletions
+47
View File
@@ -16,6 +16,7 @@
#include "apiclient.h"
#include "chatmodel.h"
#include "markdown.h"
#include "settings.h"
#include "sseparser.h"
@@ -78,6 +79,51 @@ static QString readFileTrim(const QString &path)
return QString::fromUtf8(f.readAll()).trimmed();
}
static void testMarkdown()
{
qDebug() << "== unit: markdown ==";
CHECK(Markdown::toRichText("**ciao**") == QLatin1String("<b>ciao</b>"),
"grassetto -> <b>");
CHECK(Markdown::toRichText("__ciao__") == QLatin1String("<b>ciao</b>"),
"grassetto con underscore -> <b>");
CHECK(Markdown::toRichText("*ciao*") == QLatin1String("<i>ciao</i>"),
"corsivo -> <i>");
CHECK(Markdown::toRichText("# Titolo") == QLatin1String("<b>Titolo</b>"),
"titolo -> grassetto");
CHECK(Markdown::toRichText("- uno\n- due") == QStringLiteral("• uno<br/>• due"),
"lista puntata -> bullet");
CHECK(Markdown::toRichText("1. uno\n2. due") == QLatin1String("1. uno<br/>2. due"),
"lista numerata conservata");
CHECK(Markdown::toRichText("`x = 1`").contains(QLatin1String("monospace")),
"codice inline -> monospace");
CHECK(Markdown::toRichText("[Hermes](https://x.example)") == QLatin1String("Hermes"),
"link -> solo testo");
CHECK(Markdown::toRichText("a < b & c") == QLatin1String("a &lt; b &amp; c"),
"escape di < e &");
CHECK(Markdown::toRichText("2 * 3 = 6") == QLatin1String("2 * 3 = 6"),
"asterisco spaiato lasciato intatto");
CHECK(Markdown::toRichText("> citazione") == QStringLiteral("▏ citazione"),
"citazione con barretta");
CHECK(Markdown::toRichText("```\nint x;\n```").contains(QLatin1String("int x;")),
"blocco codice: contenuto mostrato");
CHECK(Markdown::toSpeech("**ciao** *a* `b`") == QLatin1String("ciao a b"),
"speech: via enfasi e backtick");
CHECK(Markdown::toSpeech("# Titolo\n- uno\n> due") == QLatin1String("Titolo\nuno\ndue"),
"speech: titoli/liste/citazioni senza marcatori");
CHECK(Markdown::toSpeech("[t](https://x) e ![img](https://y)") == QLatin1String("t e img"),
"speech: link e immagini -> testo");
CHECK(Markdown::toSpeech("snake_case resta") == QLatin1String("snake_case resta"),
"speech: underscore in identificatori intatti");
CHECK(Markdown::toSpeech("| a | b |\n|---|---|\n| c | d |") == QLatin1String("a, b\nc, d"),
"speech: tabella compattata");
CHECK(Markdown::toSpeech("```\ncodice()\n```") == QLatin1String("codice()"),
"speech: blocco codice senza recinti");
CHECK(Markdown::toSpeech("**grassetto**\n\n\n\n*testo*") == QLatin1String("grassetto\n\ntesto"),
"speech: righe vuote compattate");
}
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
@@ -85,6 +131,7 @@ int main(int argc, char **argv)
app.setApplicationName(QStringLiteral("hermes-test"));
testParser();
testMarkdown();
if (!app.arguments().contains(QStringLiteral("--live"))) {
qDebug() << (failures == 0 ? "UNIT: tutti i test passati" : "UNIT: FALLIMENTI");