- 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.
272 lines
8.1 KiB
C++
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();
|
|
}
|