From 7428d42b92503c9fd5ca65dbd48be75769464346 Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Sun, 13 Sep 2026 08:04:19 +0200 Subject: [PATCH] feat(chat): markdown formattato nella chat + testo pulito per la voce (v0.5.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- README.md | 7 +- docs/BUILD.md | 6 +- harbour-hermes.pro | 6 +- qml/pages/ChatPage.qml | 6 +- rpm/harbour-hermes.spec | 9 +- src/apiclient.cpp | 5 +- src/chatmodel.cpp | 7 +- src/chatmodel.h | 1 + src/main.cpp | 4 +- src/markdown.cpp | 219 ++++++++++++++++++++++++++++++++++++++++ src/markdown.h | 15 +++ tests/core_test.cpp | 47 +++++++++ tests/core_test.pro | 2 + 13 files changed, 320 insertions(+), 14 deletions(-) create mode 100644 src/markdown.cpp create mode 100644 src/markdown.h diff --git a/README.md b/README.md index 9e4608d..ca81cd9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ speaks (validated live against a real instance). ## Status -Version 0.5.1 — protocol and C++ core validated against a real `hermes-webui` +Version 0.5.2 — protocol and C++ core validated against a real `hermes-webui` (login, sessions, streaming chat, TTS, STT, voice dialog, profile selection); -UI in English, Italian, French and German; every request has a watchdog -timeout (no infinite waits); on-device testing still ongoing. +UI in English, Italian, French and German; replies rendered as formatted +markdown and cleaned before read-aloud; every request has a watchdog +timeout; on-device testing still ongoing. diff --git a/docs/BUILD.md b/docs/BUILD.md index 55983f0..fd877a4 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -76,12 +76,12 @@ richiede il probe WebUI su :8899). Esito 12/09/2026: unit 9/9, live completo OK. ```bash cd ~/workspace -tar czf harbour-hermes-0.5.1.tar.gz \ - --transform 's,^harbour-hermes,harbour-hermes-0.5.1,' \ +tar czf harbour-hermes-0.5.2.tar.gz \ + --transform 's,^harbour-hermes,harbour-hermes-0.5.2,' \ --exclude='.git' harbour-hermes ``` -Lo spec si aspetta la directory `harbour-hermes-0.5.1/` (pattern degli altri +Lo spec si aspetta la directory `harbour-hermes-0.5.2/` (pattern degli altri progetti: cercato in modo robusto anche per i sorgenti live di sfdk). ## Traduzioni diff --git a/harbour-hermes.pro b/harbour-hermes.pro index fe0e5e2..67f8e2a 100644 --- a/harbour-hermes.pro +++ b/harbour-hermes.pro @@ -17,7 +17,8 @@ SOURCES += \ src/sessionsmodel.cpp \ src/recorder.cpp \ src/voicerecorder.cpp \ - src/player.cpp + src/player.cpp \ + src/markdown.cpp HEADERS += \ src/settings.h \ @@ -28,7 +29,8 @@ HEADERS += \ src/sessionsmodel.h \ src/recorder.h \ src/voicerecorder.h \ - src/player.h + src/player.h \ + src/markdown.h OTHER_FILES += \ qml/harbour-hermes.qml \ diff --git a/qml/pages/ChatPage.qml b/qml/pages/ChatPage.qml index 941cbf6..e6be5dc 100644 --- a/qml/pages/ChatPage.qml +++ b/qml/pages/ChatPage.qml @@ -107,7 +107,11 @@ Page { width: Math.min(implicitWidth, bubble.maxW - 2 * Theme.paddingMedium) wrapMode: Text.Wrap color: Theme.primaryColor - text: model.content + (model.streaming ? " ▍" : "") + // Le risposte di Hermes sono markdown: si mostrano + // formattate (grassetti, corsivi, titoli, liste, codice). + textFormat: model.role === "user" ? Text.PlainText : Text.RichText + text: (model.role === "user" ? model.content : model.contentRich) + + (model.streaming ? " ▍" : "") } } } diff --git a/rpm/harbour-hermes.spec b/rpm/harbour-hermes.spec index f1cfa3e..d716089 100644 --- a/rpm/harbour-hermes.spec +++ b/rpm/harbour-hermes.spec @@ -1,6 +1,6 @@ Name: harbour-hermes Summary: Client for the Hermes Web UI with voice -Version: 0.5.1 +Version: 0.5.2 Release: 1 Group: Qt/Qt License: MIT @@ -25,6 +25,13 @@ Sessions list, chat with live streaming replies, voice dictation (server-side TTS), plus a hands-free conversation mode. %changelog +* Sun Sep 13 2026 Carlo Baratto - 0.5.2-1 +- Chat: le risposte di Hermes sono mostrate formattate (grassetti, corsivi, + titoli, liste, citazioni, codice, link) con un convertitore markdown -> + rich text minimale (src/markdown.*, testato nel core-test); i messaggi + dell'utente restano testo semplice +- TTS: il markdown viene ripulito prima della sintesi (niente più "asterisco + asterisco" letto ad alta voce) * Sun Sep 13 2026 Carlo Baratto - 0.5.1-1 - Fix blocco sullo splash all'avvio: watchdog con timeout su tutte le richieste (QNAM non ne applica: una richiesta senza risposta lasciava diff --git a/src/apiclient.cpp b/src/apiclient.cpp index 5613698..e70110a 100644 --- a/src/apiclient.cpp +++ b/src/apiclient.cpp @@ -1,4 +1,5 @@ #include "apiclient.h" +#include "markdown.h" #include #include @@ -606,8 +607,10 @@ void ApiClient::speak(const QString &text) const QString trimmed = text.trimmed(); if (trimmed.isEmpty()) return; + QString spoken = Markdown::toSpeech(trimmed); + if (spoken.isEmpty()) + return; // Il server accetta max 5000 caratteri per richiesta: tronca con avviso. - QString spoken = trimmed; if (spoken.size() > 4900) spoken = spoken.left(4900); diff --git a/src/chatmodel.cpp b/src/chatmodel.cpp index e9732fb..d19dc21 100644 --- a/src/chatmodel.cpp +++ b/src/chatmodel.cpp @@ -1,4 +1,5 @@ #include "chatmodel.h" +#include "markdown.h" #include #include @@ -35,6 +36,9 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const return m.role; case ContentRole: return m.content; + case ContentRichRole: + // HTML per il rich text QML (markdown formattato lato C++, testabile). + return Markdown::toRichText(m.content); case StreamingRole: return m.streaming; case TimestampRole: @@ -49,6 +53,7 @@ QHash ChatModel::roleNames() const QHash roles; roles.insert(RoleRole, "role"); roles.insert(ContentRole, "content"); + roles.insert(ContentRichRole, "contentRich"); roles.insert(StreamingRole, "streaming"); roles.insert(TimestampRole, "timestamp"); return roles; @@ -167,7 +172,7 @@ void ChatModel::onStreamDelta(const QString &text) } last.content += text; const QModelIndex idx = index(m_msgs.size() - 1, 0); - emit dataChanged(idx, idx, QVector() << ContentRole << StreamingRole); + emit dataChanged(idx, idx, QVector() << ContentRole << ContentRichRole << StreamingRole); emit streamTick(); } diff --git a/src/chatmodel.h b/src/chatmodel.h index c0bff0f..55accb5 100644 --- a/src/chatmodel.h +++ b/src/chatmodel.h @@ -30,6 +30,7 @@ public: enum Roles { RoleRole = Qt::UserRole + 1, ContentRole, + ContentRichRole, StreamingRole, TimestampRole }; diff --git a/src/main.cpp b/src/main.cpp index 475c6c6..ddb316a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,9 +21,9 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) // AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox). app->setOrganizationName(QStringLiteral("harbour")); app->setApplicationName(QStringLiteral("hermes")); - app->setApplicationVersion(QStringLiteral("0.5.1")); + app->setApplicationVersion(QStringLiteral("0.5.2")); - qDebug() << "harbour-hermes v0.5.1 build" << __DATE__ << __TIME__; + qDebug() << "harbour-hermes v0.5.2 build" << __DATE__ << __TIME__; // Traduzioni: it/fr/de da /usr/share/harbour-hermes/translations, in base // alla lingua di sistema (l'inglese e' la lingua sorgente: nessun .qm). diff --git a/src/markdown.cpp b/src/markdown.cpp new file mode 100644 index 0000000..ce20830 --- /dev/null +++ b/src/markdown.cpp @@ -0,0 +1,219 @@ +#include "markdown.h" + +#include +#include + +// Nota: il testo arriva dall'agente (fidato ma non nostro), quindi va +// escapato per il rich text Qt prima di inserire i NOSTRI tag. +// Ordine: escape -> inline (link, grassetto, corsivo) -> blocchi. + +namespace { + +QString escapeHtml(const QString &s) +{ + QString out; + out.reserve(s.size()); + for (int i = 0; i < s.size(); ++i) { + const QChar c = s.at(i); + if (c == QLatin1Char('&')) + out += QLatin1String("&"); + else if (c == QLatin1Char('<')) + out += QLatin1String("<"); + else if (c == QLatin1Char('>')) + out += QLatin1String(">"); + else + out += c; + } + return out; +} + +// Inline del rich text (input gia' escapato): codice, link, grassetto, +// corsivo. I code span vengono protetti con segnaposto \x01N\x02 per non +// subire le altre sostituzioni e ripristinati alla fine in monospace. +QString inlineRich(QString s) +{ + QStringList codes; + const QRegularExpression codeRe(QStringLiteral("`([^`\n]+)`")); + int offset = 0; + QRegularExpressionMatch m; + while ((m = codeRe.match(s, offset)).hasMatch()) { + const QString token = QString(QChar(1)) + QString::number(codes.size()) + + QString(QChar(2)); + codes << QStringLiteral("%1") + .arg(m.captured(1)); + s.replace(m.capturedStart(), m.capturedLength(), token); + offset = m.capturedStart() + token.size(); + } + + // [testo](url) -> testo (il markdown non puo' contenere link cliccabili + // utili su questo client: meglio il testo pulito). + s.replace(QRegularExpression(QStringLiteral("\\[([^\\[\\]]+)\\]\\(([^()\\s]*)\\)")), + QStringLiteral("\\1")); + // Grassetto **x** / __x__, corsivo *x* / _x_ (con confini di parola per + // non toccare gli underscore degli identificatori), barrato ~~x~~. + s.replace(QRegularExpression(QStringLiteral("\\*\\*([^*\\n]+)\\*\\*")), + QStringLiteral("\\1")); + s.replace(QRegularExpression(QStringLiteral("\\b__([^_\\n]+)__\\b")), + QStringLiteral("\\1")); + s.replace(QRegularExpression(QStringLiteral("(?\\1")); + s.replace(QRegularExpression(QStringLiteral("\\b_([^_\\n]+)_\\b")), + QStringLiteral("\\1")); + s.replace(QRegularExpression(QStringLiteral("~~([^~\\n]+)~~")), + QStringLiteral("\\1")); + + for (int i = 0; i < codes.size(); ++i) { + const QString token = QString(QChar(1)) + QString::number(i) + + QString(QChar(2)); + s.replace(token, codes.at(i)); + } + return s; +} + +QString toRichTextImpl(const QString &md) +{ + QString text = md; + text.replace(QLatin1String("\r\n"), QLatin1String("\n")); + text.replace(QLatin1Char('\r'), QLatin1Char('\n')); + + const QStringList lines = text.split(QLatin1Char('\n')); + QStringList out; + bool inCode = false; + + const QRegularExpression reHeading(QStringLiteral("^\\s{0,3}(#{1,6})\\s+(.*)$")); + const QRegularExpression reQuote(QStringLiteral("^\\s*>\\s?(.*)$")); + const QRegularExpression reBullet(QStringLiteral("^(\\s*)[-*+]\\s+(.*)$")); + const QRegularExpression reOrdered(QStringLiteral("^(\\s*)(\\d+[.)])\\s+(.*)$")); + const QRegularExpression reRule(QStringLiteral("^\\s{0,3}([-*_])(\\s*\\1){2,}\\s*$")); + const QRegularExpression reTableSep(QStringLiteral("^\\s*\\|?[\\s:|-]+\\|?\\s*$")); + + for (int i = 0; i < lines.size(); ++i) { + const QString line = lines.at(i); + const QString trimmed = line.trimmed(); + + if (trimmed.startsWith(QLatin1String("```"))) { + inCode = !inCode; + continue; // i recinti non si mostrano + } + if (inCode) { + out << QStringLiteral("%1") + .arg(escapeHtml(line)); + continue; + } + if (trimmed.isEmpty()) { + out << QStringLiteral("
"); + continue; + } + if (reRule.match(trimmed).hasMatch()) + continue; // --- separatori: via + if (trimmed.contains(QLatin1Char('-')) && trimmed.contains(QLatin1Char('|')) + && reTableSep.match(trimmed).hasMatch()) + continue; // |---|---| di tabella: via + + QRegularExpressionMatch m = reHeading.match(trimmed); + if (m.hasMatch()) { + out << QStringLiteral("%1") + .arg(inlineRich(escapeHtml(m.captured(2)))); + continue; + } + m = reQuote.match(line); + if (m.hasMatch()) { + out << QStringLiteral("▏ %1").arg(inlineRich(escapeHtml(m.captured(1)))); + continue; + } + m = reBullet.match(line); + if (m.hasMatch()) { + out << QStringLiteral("%1• %2") + .arg(m.captured(1), inlineRich(escapeHtml(m.captured(2)))); + continue; + } + m = reOrdered.match(line); + if (m.hasMatch()) { + out << QStringLiteral("%1%2 %3") + .arg(m.captured(1), m.captured(2), + inlineRich(escapeHtml(m.captured(3)))); + continue; + } + out << inlineRich(escapeHtml(line)); + } + + QString html = out.join(QStringLiteral("
")); + while (html.contains(QLatin1String("


"))) + html.replace(QLatin1String("


"), QLatin1String("

")); + while (html.startsWith(QLatin1String("
"))) + html.remove(0, 5); + while (html.endsWith(QLatin1String("
"))) + html.chop(5); + return html; +} + +QString toSpeechImpl(const QString &md) +{ + QString text = md; + text.replace(QLatin1String("\r\n"), QLatin1String("\n")); + text.replace(QLatin1Char('\r'), QLatin1Char('\n')); + + const QStringList lines = text.split(QLatin1Char('\n')); + QStringList out; + bool inCode = false; + + const QRegularExpression reRule(QStringLiteral("^\\s{0,3}([-*_])(\\s*\\1){2,}\\s*$")); + const QRegularExpression reTableSep(QStringLiteral("^\\s*\\|?[\\s:|-]+\\|?\\s*$")); + + for (int i = 0; i < lines.size(); ++i) { + QString line = lines.at(i); + const QString trimmed = line.trimmed(); + + if (trimmed.startsWith(QLatin1String("```"))) { + inCode = !inCode; + continue; + } + if (inCode) { + out << trimmed; // il contenuto del codice resta + continue; + } + if (reRule.match(trimmed).hasMatch()) + continue; + if (trimmed.contains(QLatin1Char('-')) && trimmed.contains(QLatin1Char('|')) + && reTableSep.match(trimmed).hasMatch()) + continue; + + line.remove(QRegularExpression(QStringLiteral("^\\s*>\\s?"))); // citazione + line.remove(QRegularExpression(QStringLiteral("^\\s{0,3}#{1,6}\\s+"))); // titolo + line.remove(QRegularExpression(QStringLiteral("^\\s*[-*+]\\s+"))); // liste + // Link e immagini -> solo testo; via backtick e marcatori di enfasi. + line.replace(QRegularExpression(QStringLiteral("!?\\[([^\\[\\]]*)\\]\\(([^()\\s]*)\\)")), + QStringLiteral("\\1")); + line.remove(QLatin1Char('`')); + line.remove(QRegularExpression(QStringLiteral("\\*\\*|__|~~"))); + line.replace(QRegularExpression(QStringLiteral("\\*([^*\\n]+)\\*")), + QStringLiteral("\\1")); + line.replace(QRegularExpression(QStringLiteral("\\b_([^_\\n]+)_\\b")), + QStringLiteral("\\1")); + // Righe di tabella "| a | b |" -> "a, b". + if (line.count(QLatin1Char('|')) >= 2) { + line.remove(QRegularExpression(QStringLiteral("^\\s*\\|"))); + line.remove(QRegularExpression(QStringLiteral("\\|\\s*$"))); + line.replace(QRegularExpression(QStringLiteral("\\s*\\|\\s*")), + QStringLiteral(", ")); + } + out << line.trimmed(); + } + + QString plain = out.join(QLatin1Char('\n')); + plain.replace(QRegularExpression(QStringLiteral("[ \\t]+")), QStringLiteral(" ")); + plain.replace(QRegularExpression(QStringLiteral("\\n{3,}")), QStringLiteral("\n\n")); + return plain.trimmed(); +} + +} // namespace + +QString Markdown::toRichText(const QString &md) +{ + return toRichTextImpl(md); +} + +QString Markdown::toSpeech(const QString &md) +{ + return toSpeechImpl(md); +} diff --git a/src/markdown.h b/src/markdown.h new file mode 100644 index 0000000..a8c9e45 --- /dev/null +++ b/src/markdown.h @@ -0,0 +1,15 @@ +#ifndef MARKDOWN_H +#define MARKDOWN_H + +#include + +// Conversione minimale del markdown delle risposte di Hermes: +// - toRichText(): HTML per QML Text.RichText (grassetti, corsivi, titoli, +// liste, citazioni, codice, link) — il sottoinsieme dei rich text Qt; +// - toSpeech(): testo piatto per il TTS, senza simboli di formattazione. +namespace Markdown { +QString toRichText(const QString &md); +QString toSpeech(const QString &md); +} + +#endif // MARKDOWN_H diff --git a/tests/core_test.cpp b/tests/core_test.cpp index 48ea6c7..e6dac50 100644 --- a/tests/core_test.cpp +++ b/tests/core_test.cpp @@ -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("ciao"), + "grassetto -> "); + CHECK(Markdown::toRichText("__ciao__") == QLatin1String("ciao"), + "grassetto con underscore -> "); + CHECK(Markdown::toRichText("*ciao*") == QLatin1String("ciao"), + "corsivo -> "); + CHECK(Markdown::toRichText("# Titolo") == QLatin1String("Titolo"), + "titolo -> grassetto"); + CHECK(Markdown::toRichText("- uno\n- due") == QStringLiteral("• uno
• due"), + "lista puntata -> bullet"); + CHECK(Markdown::toRichText("1. uno\n2. due") == QLatin1String("1. uno
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 < b & 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"); diff --git a/tests/core_test.pro b/tests/core_test.pro index 6a6a4c2..485ef41 100644 --- a/tests/core_test.pro +++ b/tests/core_test.pro @@ -23,6 +23,7 @@ SOURCES += \ $$SRC/recorder.cpp \ $$SRC/voicerecorder.cpp \ $$SRC/player.cpp \ + $$SRC/markdown.cpp \ core_test.cpp HEADERS += \ @@ -33,5 +34,6 @@ HEADERS += \ $$SRC/chatmodel.h \ $$SRC/sessionsmodel.h \ $$SRC/recorder.h \ + $$SRC/markdown.h \ $$SRC/voicerecorder.h \ $$SRC/player.h