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:
2026-09-12 07:11:20 +02:00
commit 9c6a1242f7
36 changed files with 2954 additions and 0 deletions
+252
View File
@@ -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();
}