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:
@@ -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
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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 ? " ▍" : "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 < 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 ") == 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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user