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:
+4
-1
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ public:
|
||||
enum Roles {
|
||||
RoleRole = Qt::UserRole + 1,
|
||||
ContentRole,
|
||||
ContentRichRole,
|
||||
StreamingRole,
|
||||
TimestampRole
|
||||
};
|
||||
|
||||
+2
-2
@@ -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).
|
||||
|
||||
@@ -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("&");
|
||||
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("<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);
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user