Files
harbour-hermes/src/chatmodel.cpp
T
kaneda 7428d42b92 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.
2026-09-13 08:04:19 +02:00

272 lines
8.1 KiB
C++

#include "chatmodel.h"
#include "markdown.h"
#include <QDebug>
#include <QTimer>
ChatModel::ChatModel(ApiClient *api, QObject *parent)
: QAbstractListModel(parent)
, m_api(api)
, m_busy(false)
, m_finalizedThisRun(false)
{
connect(m_api, &ApiClient::sessionOpened, this, &ChatModel::onSessionOpened);
connect(m_api, &ApiClient::chatStarted, this, &ChatModel::onChatStarted);
connect(m_api, &ApiClient::chatStartFailed, this, &ChatModel::onChatStartFailed);
connect(m_api, &ApiClient::streamDelta, this, &ChatModel::onStreamDelta);
connect(m_api, &ApiClient::streamDone, this, &ChatModel::onStreamDone);
connect(m_api, &ApiClient::streamEnded, this, &ChatModel::onStreamEnded);
connect(m_api, &ApiClient::streamError, this, &ChatModel::onStreamError);
}
int ChatModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return m_msgs.size();
}
QVariant ChatModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_msgs.size())
return QVariant();
const Msg &m = m_msgs.at(index.row());
switch (role) {
case RoleRole:
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:
return m.ts;
default:
return QVariant();
}
}
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;
}
// ------------------------------------------------------------ flusso chat --
void ChatModel::openSession(const QString &id)
{
m_sessionId = id;
m_title.clear();
setFromMessages(QVariantList());
emit sessionInfoChanged();
m_api->openSession(id);
}
void ChatModel::newSession()
{
m_title.clear();
setFromMessages(QVariantList());
emit sessionInfoChanged();
m_api->newSession();
}
void ChatModel::resync()
{
if (!m_sessionId.isEmpty())
m_api->openSession(m_sessionId);
}
void ChatModel::send(const QString &text)
{
const QString trimmed = text.trimmed();
if (trimmed.isEmpty())
return;
// Rifiuti VISIBILI: prima erano silenziosi, e l'invio automatico poteva
// "non fare nulla" senza che si capisse il perché.
if (m_sessionId.isEmpty()) {
qDebug() << "[chat] send rifiutato: nessuna sessione aperta";
emit notice(tr("No session open"));
return;
}
if (m_busy) {
qDebug() << "[chat] send rifiutato: un turno e' gia' in corso";
emit notice(tr("Wait for the current reply"));
return;
}
qDebug() << "[chat] invio:" << trimmed.size() << "caratteri";
m_finalizedThisRun = false;
appendMessage(QStringLiteral("user"), trimmed, false);
beginAssistant();
setBusy(true);
m_api->startChat(m_sessionId, trimmed);
}
void ChatModel::cancel()
{
m_api->cancelRun();
}
QString ChatModel::lastAssistantText() const
{
for (int i = m_msgs.size() - 1; i >= 0; --i) {
const Msg &m = m_msgs.at(i);
if (m.role == QLatin1String("assistant") && !m.content.trimmed().isEmpty())
return m.content;
}
return QString();
}
void ChatModel::onSessionOpened()
{
const QVariantMap s = m_api->session();
const QString sid = s.value(QStringLiteral("session_id")).toString();
if (!sid.isEmpty())
m_sessionId = sid;
m_title = s.value(QStringLiteral("title")).toString();
setFromMessages(s.value(QStringLiteral("messages")).toList());
emit sessionInfoChanged();
// Ripresa: la sessione aveva un run attivo e lo stream e' stato riagganciato.
if (m_api->streaming()) {
beginAssistant();
setBusy(true);
}
}
void ChatModel::onChatStarted(const QString &streamId)
{
Q_UNUSED(streamId)
if (m_msgs.isEmpty() || m_msgs.last().role != QLatin1String("assistant"))
beginAssistant();
setBusy(true);
}
void ChatModel::onChatStartFailed(const QString &error)
{
// Rimuove il placeholder assistente rimasto vuoto.
if (m_msgs.size() > 0 && m_msgs.last().role == QLatin1String("assistant")
&& m_msgs.last().content.isEmpty()) {
beginRemoveRows(QModelIndex(), m_msgs.size() - 1, m_msgs.size() - 1);
m_msgs.removeLast();
endRemoveRows();
emit rowCountChanged();
}
setBusy(false);
emit notice(error);
}
void ChatModel::onStreamDelta(const QString &text)
{
if (m_msgs.isEmpty() || m_msgs.last().role != QLatin1String("assistant"))
beginAssistant();
Msg &last = m_msgs.last();
if (!last.streaming) {
last.streaming = true;
}
last.content += text;
const QModelIndex idx = index(m_msgs.size() - 1, 0);
emit dataChanged(idx, idx, QVector<int>() << ContentRole << ContentRichRole << StreamingRole);
emit streamTick();
}
void ChatModel::onStreamDone(const QVariantMap &session)
{
m_finalizedThisRun = true;
m_title = session.value(QStringLiteral("title")).toString();
setFromMessages(session.value(QStringLiteral("messages")).toList());
emit sessionInfoChanged();
setBusy(false);
emit assistantFinished();
}
void ChatModel::onStreamEnded()
{
if (!m_msgs.isEmpty() && m_msgs.last().streaming) {
Msg &last = m_msgs.last();
last.streaming = false;
// Placeholder rimasto vuoto (run interrotto prima del primo token).
if (last.role == QLatin1String("assistant") && last.content.isEmpty()) {
beginRemoveRows(QModelIndex(), m_msgs.size() - 1, m_msgs.size() - 1);
m_msgs.removeLast();
endRemoveRows();
emit rowCountChanged();
} else {
const QModelIndex idx = index(m_msgs.size() - 1, 0);
emit dataChanged(idx, idx, QVector<int>() << StreamingRole);
}
}
setBusy(false);
// Aggiorna la lista sessioni (titolo/ordinamento possono essere cambiati).
m_api->refreshSessions();
}
void ChatModel::onStreamError(const QString &error)
{
emit notice(error);
// Lo stream puo' cadere per sospensione dell'app: risincronizza dallo stato
// del server tra un attimo (se nel frattempo non e' gia' ripartito qualcosa).
QTimer::singleShot(1200, this, [this]() {
if (!m_api->streaming() && !m_sessionId.isEmpty())
m_api->openSession(m_sessionId);
});
}
// ------------------------------------------------------------- modello ----
void ChatModel::setFromMessages(const QVariantList &messages)
{
beginResetModel();
m_msgs.clear();
for (int i = 0; i < messages.size(); ++i) {
const QVariantMap m = messages.at(i).toMap();
const QString role = m.value(QStringLiteral("role")).toString();
if (role != QLatin1String("user") && role != QLatin1String("assistant"))
continue;
const QString content = m.value(QStringLiteral("content")).toString();
// Messaggi assistente vuoti (solo chiamate a strumenti): non mostrati.
if (role == QLatin1String("assistant") && content.trimmed().isEmpty())
continue;
Msg msg;
msg.role = role;
msg.content = content;
msg.streaming = false;
msg.ts = m.value(QStringLiteral("timestamp")).toDouble();
m_msgs.append(msg);
}
endResetModel();
emit rowCountChanged();
}
void ChatModel::appendMessage(const QString &role, const QString &content, bool streaming)
{
beginInsertRows(QModelIndex(), m_msgs.size(), m_msgs.size());
Msg msg;
msg.role = role;
msg.content = content;
msg.streaming = streaming;
msg.ts = 0;
m_msgs.append(msg);
endInsertRows();
emit rowCountChanged();
}
void ChatModel::beginAssistant()
{
appendMessage(QStringLiteral("assistant"), QString(), true);
}
void ChatModel::setBusy(bool on)
{
if (on == m_busy)
return;
m_busy = on;
emit busyChanged();
}