feat: v0.1.0 — client Sailfish per hermes-webui con voce
- ApiClient HTTP/SSE: login, sessioni, chat/start, stream SSE, transcribe, tts - ChatModel: streaming live + risincronizzazione dallo stato del server - CookieJar persistente (0600, mai la password); Settings - Recorder OGG/Opus (fallback WAV/PCM, retry); Player TTS (QMediaPlayer) - UI Silica: splash, login, sessioni, chat (dettatura + read-aloud), settings, cover - Sailjail Internet;Audio;Microphone; icone; spec RPM; docs PROTOCOL/BUILD - tests/core_test: unit parser SSE + integrazione live (probe :8899)
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
#include "chatmodel.h"
|
||||
|
||||
#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 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(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() || m_sessionId.isEmpty() || m_busy)
|
||||
return;
|
||||
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 << 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();
|
||||
}
|
||||
Reference in New Issue
Block a user