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 -1
View File
@@ -1,4 +1,5 @@
#include "apiclient.h"
#include "markdown.h"
#include <QDateTime>
#include <QDebug>
@@ -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);
+6 -1
View File
@@ -1,4 +1,5 @@
#include "chatmodel.h"
#include "markdown.h"
#include <QDebug>
#include <QTimer>
@@ -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<int, QByteArray> ChatModel::roleNames() const
QHash<int, QByteArray> 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<int>() << ContentRole << StreamingRole);
emit dataChanged(idx, idx, QVector<int>() << ContentRole << ContentRichRole << StreamingRole);
emit streamTick();
}
+1
View File
@@ -30,6 +30,7 @@ public:
enum Roles {
RoleRole = Qt::UserRole + 1,
ContentRole,
ContentRichRole,
StreamingRole,
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).
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).
+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