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
+4 -3
View File
@@ -76,7 +76,8 @@ speaks (validated live against a real instance).
## Status ## 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); (login, sessions, streaming chat, TTS, STT, voice dialog, profile selection);
UI in English, Italian, French and German; every request has a watchdog UI in English, Italian, French and German; replies rendered as formatted
timeout (no infinite waits); on-device testing still ongoing. markdown and cleaned before read-aloud; every request has a watchdog
timeout; on-device testing still ongoing.
+3 -3
View File
@@ -76,12 +76,12 @@ richiede il probe WebUI su :8899). Esito 12/09/2026: unit 9/9, live completo OK.
```bash ```bash
cd ~/workspace cd ~/workspace
tar czf harbour-hermes-0.5.1.tar.gz \ tar czf harbour-hermes-0.5.2.tar.gz \
--transform 's,^harbour-hermes,harbour-hermes-0.5.1,' \ --transform 's,^harbour-hermes,harbour-hermes-0.5.2,' \
--exclude='.git' harbour-hermes --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). progetti: cercato in modo robusto anche per i sorgenti live di sfdk).
## Traduzioni ## Traduzioni
+4 -2
View File
@@ -17,7 +17,8 @@ SOURCES += \
src/sessionsmodel.cpp \ src/sessionsmodel.cpp \
src/recorder.cpp \ src/recorder.cpp \
src/voicerecorder.cpp \ src/voicerecorder.cpp \
src/player.cpp src/player.cpp \
src/markdown.cpp
HEADERS += \ HEADERS += \
src/settings.h \ src/settings.h \
@@ -28,7 +29,8 @@ HEADERS += \
src/sessionsmodel.h \ src/sessionsmodel.h \
src/recorder.h \ src/recorder.h \
src/voicerecorder.h \ src/voicerecorder.h \
src/player.h src/player.h \
src/markdown.h
OTHER_FILES += \ OTHER_FILES += \
qml/harbour-hermes.qml \ qml/harbour-hermes.qml \
+5 -1
View File
@@ -107,7 +107,11 @@ Page {
width: Math.min(implicitWidth, bubble.maxW - 2 * Theme.paddingMedium) width: Math.min(implicitWidth, bubble.maxW - 2 * Theme.paddingMedium)
wrapMode: Text.Wrap wrapMode: Text.Wrap
color: Theme.primaryColor 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 ? " ▍" : "")
} }
} }
} }
+8 -1
View File
@@ -1,6 +1,6 @@
Name: harbour-hermes Name: harbour-hermes
Summary: Client for the Hermes Web UI with voice Summary: Client for the Hermes Web UI with voice
Version: 0.5.1 Version: 0.5.2
Release: 1 Release: 1
Group: Qt/Qt Group: Qt/Qt
License: MIT License: MIT
@@ -25,6 +25,13 @@ Sessions list, chat with live streaming replies, voice dictation
(server-side TTS), plus a hands-free conversation mode. (server-side TTS), plus a hands-free conversation mode.
%changelog %changelog
* Sun Sep 13 2026 Carlo Baratto <carlo@carlobaratto.it> - 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 <carlo@carlobaratto.it> - 0.5.1-1 * Sun Sep 13 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.5.1-1
- Fix blocco sullo splash all'avvio: watchdog con timeout su tutte le - Fix blocco sullo splash all'avvio: watchdog con timeout su tutte le
richieste (QNAM non ne applica: una richiesta senza risposta lasciava richieste (QNAM non ne applica: una richiesta senza risposta lasciava
+4 -1
View File
@@ -1,4 +1,5 @@
#include "apiclient.h" #include "apiclient.h"
#include "markdown.h"
#include <QDateTime> #include <QDateTime>
#include <QDebug> #include <QDebug>
@@ -606,8 +607,10 @@ void ApiClient::speak(const QString &text)
const QString trimmed = text.trimmed(); const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) if (trimmed.isEmpty())
return; return;
QString spoken = Markdown::toSpeech(trimmed);
if (spoken.isEmpty())
return;
// Il server accetta max 5000 caratteri per richiesta: tronca con avviso. // Il server accetta max 5000 caratteri per richiesta: tronca con avviso.
QString spoken = trimmed;
if (spoken.size() > 4900) if (spoken.size() > 4900)
spoken = spoken.left(4900); spoken = spoken.left(4900);
+6 -1
View File
@@ -1,4 +1,5 @@
#include "chatmodel.h" #include "chatmodel.h"
#include "markdown.h"
#include <QDebug> #include <QDebug>
#include <QTimer> #include <QTimer>
@@ -35,6 +36,9 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
return m.role; return m.role;
case ContentRole: case ContentRole:
return m.content; return m.content;
case ContentRichRole:
// HTML per il rich text QML (markdown formattato lato C++, testabile).
return Markdown::toRichText(m.content);
case StreamingRole: case StreamingRole:
return m.streaming; return m.streaming;
case TimestampRole: case TimestampRole:
@@ -49,6 +53,7 @@ QHash<int, QByteArray> ChatModel::roleNames() const
QHash<int, QByteArray> roles; QHash<int, QByteArray> roles;
roles.insert(RoleRole, "role"); roles.insert(RoleRole, "role");
roles.insert(ContentRole, "content"); roles.insert(ContentRole, "content");
roles.insert(ContentRichRole, "contentRich");
roles.insert(StreamingRole, "streaming"); roles.insert(StreamingRole, "streaming");
roles.insert(TimestampRole, "timestamp"); roles.insert(TimestampRole, "timestamp");
return roles; return roles;
@@ -167,7 +172,7 @@ void ChatModel::onStreamDelta(const QString &text)
} }
last.content += text; last.content += text;
const QModelIndex idx = index(m_msgs.size() - 1, 0); const QModelIndex idx = index(m_msgs.size() - 1, 0);
emit dataChanged(idx, idx, QVector<int>() << ContentRole << StreamingRole); emit dataChanged(idx, idx, QVector<int>() << ContentRole << ContentRichRole << StreamingRole);
emit streamTick(); emit streamTick();
} }
+1
View File
@@ -30,6 +30,7 @@ public:
enum Roles { enum Roles {
RoleRole = Qt::UserRole + 1, RoleRole = Qt::UserRole + 1,
ContentRole, ContentRole,
ContentRichRole,
StreamingRole, StreamingRole,
TimestampRole TimestampRole
}; };
+2 -2
View File
@@ -21,9 +21,9 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
// AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox). // AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox).
app->setOrganizationName(QStringLiteral("harbour")); app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("hermes")); 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 // Traduzioni: it/fr/de da /usr/share/harbour-hermes/translations, in base
// alla lingua di sistema (l'inglese e' la lingua sorgente: nessun .qm). // alla lingua di sistema (l'inglese e' la lingua sorgente: nessun .qm).
+219
View File
@@ -0,0 +1,219 @@
#include "markdown.h"
#include <QRegularExpression>
#include <QStringList>
// 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("&amp;");
else if (c == QLatin1Char('<'))
out += QLatin1String("&lt;");
else if (c == QLatin1Char('>'))
out += QLatin1String("&gt;");
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("<font family=\"monospace\">%1</font>")
.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("<b>\\1</b>"));
s.replace(QRegularExpression(QStringLiteral("\\b__([^_\\n]+)__\\b")),
QStringLiteral("<b>\\1</b>"));
s.replace(QRegularExpression(QStringLiteral("(?<!\\*)\\*([^*\\n]+)\\*(?!\\*)")),
QStringLiteral("<i>\\1</i>"));
s.replace(QRegularExpression(QStringLiteral("\\b_([^_\\n]+)_\\b")),
QStringLiteral("<i>\\1</i>"));
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("<font family=\"monospace\">%1</font>")
.arg(escapeHtml(line));
continue;
}
if (trimmed.isEmpty()) {
out << QStringLiteral("<br/>");
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("<b>%1</b>")
.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("<br/>"));
while (html.contains(QLatin1String("<br/><br/><br/>")))
html.replace(QLatin1String("<br/><br/><br/>"), QLatin1String("<br/><br/>"));
while (html.startsWith(QLatin1String("<br/>")))
html.remove(0, 5);
while (html.endsWith(QLatin1String("<br/>")))
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);
}
+15
View File
@@ -0,0 +1,15 @@
#ifndef MARKDOWN_H
#define MARKDOWN_H
#include <QString>
// 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
+47
View File
@@ -16,6 +16,7 @@
#include "apiclient.h" #include "apiclient.h"
#include "chatmodel.h" #include "chatmodel.h"
#include "markdown.h"
#include "settings.h" #include "settings.h"
#include "sseparser.h" #include "sseparser.h"
@@ -78,6 +79,51 @@ static QString readFileTrim(const QString &path)
return QString::fromUtf8(f.readAll()).trimmed(); 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) int main(int argc, char **argv)
{ {
QCoreApplication app(argc, argv); QCoreApplication app(argc, argv);
@@ -85,6 +131,7 @@ int main(int argc, char **argv)
app.setApplicationName(QStringLiteral("hermes-test")); app.setApplicationName(QStringLiteral("hermes-test"));
testParser(); testParser();
testMarkdown();
if (!app.arguments().contains(QStringLiteral("--live"))) { if (!app.arguments().contains(QStringLiteral("--live"))) {
qDebug() << (failures == 0 ? "UNIT: tutti i test passati" : "UNIT: FALLIMENTI"); qDebug() << (failures == 0 ? "UNIT: tutti i test passati" : "UNIT: FALLIMENTI");
+2
View File
@@ -23,6 +23,7 @@ SOURCES += \
$$SRC/recorder.cpp \ $$SRC/recorder.cpp \
$$SRC/voicerecorder.cpp \ $$SRC/voicerecorder.cpp \
$$SRC/player.cpp \ $$SRC/player.cpp \
$$SRC/markdown.cpp \
core_test.cpp core_test.cpp
HEADERS += \ HEADERS += \
@@ -33,5 +34,6 @@ HEADERS += \
$$SRC/chatmodel.h \ $$SRC/chatmodel.h \
$$SRC/sessionsmodel.h \ $$SRC/sessionsmodel.h \
$$SRC/recorder.h \ $$SRC/recorder.h \
$$SRC/markdown.h \
$$SRC/voicerecorder.h \ $$SRC/voicerecorder.h \
$$SRC/player.h $$SRC/player.h