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
+505
View File
@@ -0,0 +1,505 @@
#include "apiclient.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
#include <QUrlQuery>
ApiClient::ApiClient(Settings *settings, QObject *parent)
: QObject(parent)
, m_settings(settings)
, m_jar(new CookieJar(this))
, m_loggedIn(false)
, m_busy(false)
, m_streaming(false)
, m_transcribing(false)
, m_streamReply(nullptr)
, m_streamEndedEmitted(false)
{
// Cookie di sessione persistente (0600), nella zona config della sandbox.
const QString cfg = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
QDir().mkpath(cfg);
m_jar->setFilePath(cfg + QStringLiteral("/cookies.dat"));
m_jar->load();
m_nam.setCookieJar(m_jar);
}
// ---------------------------------------------------------------- helpers --
QUrl ApiClient::apiUrl(const QString &path) const
{
return QUrl(m_settings->baseUrl() + path);
}
QNetworkRequest ApiClient::jsonRequest(const QString &path, bool post) const
{
Q_UNUSED(post)
QNetworkRequest req(apiUrl(path));
req.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/json"));
return req;
}
QString ApiClient::cacheDir(const QString &sub) const
{
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
const QString dir = base + QStringLiteral("/") + sub;
QDir().mkpath(dir);
return dir;
}
QString ApiClient::errorFromReply(QNetworkReply *reply, const QByteArray &body) const
{
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (doc.isObject()) {
const QString err = doc.object().value(QStringLiteral("error")).toString();
if (!err.isEmpty())
return err;
}
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status == 401)
return QStringLiteral("Authentication required");
if (status > 0)
return QStringLiteral("HTTP %1").arg(status);
return reply->errorString();
}
void ApiClient::setBusy(bool on)
{
if (on == m_busy)
return;
m_busy = on;
emit busyChanged();
}
void ApiClient::setStreaming(bool on)
{
if (on == m_streaming)
return;
m_streaming = on;
emit streamingChanged();
}
void ApiClient::setTranscribing(bool on)
{
if (on == m_transcribing)
return;
m_transcribing = on;
emit transcribingChanged();
}
void ApiClient::setLastError(const QString &error)
{
if (error == m_lastError)
return;
m_lastError = error;
emit lastErrorChanged();
}
void ApiClient::setLoggedIn(bool on)
{
if (on == m_loggedIn)
return;
m_loggedIn = on;
emit loggedInChanged();
}
void ApiClient::clearError()
{
setLastError(QString());
}
// ------------------------------------------------------------------- auth --
void ApiClient::checkAuth()
{
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/auth/status")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLoggedIn(false);
emit authChecked(false);
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
const bool authEnabled = obj.value(QStringLiteral("auth_enabled")).toBool();
const bool sessionValid = obj.value(QStringLiteral("logged_in")).toBool();
if (!authEnabled || sessionValid) {
setLoggedIn(true);
refreshSessions();
} else {
setLoggedIn(false);
}
emit authChecked(m_loggedIn);
});
}
void ApiClient::login(const QString &password)
{
setLastError(QString());
setBusy(true);
QJsonObject body;
body.insert(QStringLiteral("password"), password);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/login")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, data));
return;
}
setLoggedIn(true);
refreshSessions();
});
}
void ApiClient::logout()
{
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/logout")), QByteArray());
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
});
m_jar->clear();
setLoggedIn(false);
m_sessions.clear();
emit sessionsChanged();
m_session.clear();
emit sessionChanged();
}
// --------------------------------------------------------------- sessioni --
void ApiClient::refreshSessions()
{
setBusy(true);
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/sessions")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, body));
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
const QJsonArray arr = obj.value(QStringLiteral("sessions")).toArray();
m_sessions = arr.toVariantList();
emit sessionsChanged();
});
}
void ApiClient::openSession(const QString &id)
{
setLastError(QString());
setBusy(true);
QUrl url = apiUrl(QStringLiteral("/api/session"));
QUrlQuery q;
q.addQueryItem(QStringLiteral("session_id"), id);
url.setQuery(q);
QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, body));
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
m_session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
emit sessionChanged();
emit sessionOpened();
// Se la sessione ha gia' un run attivo (ripresa dopo chiusura app),
// riaggancia lo stream SSE.
const QString active = m_session.value(QStringLiteral("active_stream_id")).toString();
if (!active.isEmpty())
startStream(active);
});
}
void ApiClient::newSession()
{
setLastError(QString());
setBusy(true);
QJsonObject body;
const QString ws = m_settings->defaultWorkspace();
if (!ws.isEmpty())
body.insert(QStringLiteral("workspace"), ws);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/session/new")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, data));
return;
}
const QJsonObject obj = QJsonDocument::fromJson(data).object();
m_session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
emit sessionChanged();
emit sessionOpened();
refreshSessions();
});
}
// ------------------------------------------------------------------- chat --
void ApiClient::startChat(const QString &sessionId, const QString &message)
{
setLastError(QString());
QJsonObject body;
body.insert(QStringLiteral("session_id"), sessionId);
body.insert(QStringLiteral("message"), message);
const QString model = m_session.value(QStringLiteral("model")).toString();
const QString provider = m_session.value(QStringLiteral("model_provider")).toString();
const QString workspace = m_session.value(QStringLiteral("workspace")).toString();
QString profile = m_session.value(QStringLiteral("profile")).toString();
if (profile.isEmpty())
profile = QStringLiteral("default");
body.insert(QStringLiteral("model"), model);
body.insert(QStringLiteral("model_provider"), provider);
body.insert(QStringLiteral("workspace"), workspace);
body.insert(QStringLiteral("profile"), profile);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/chat/start")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
handleStartReply(reply);
});
}
void ApiClient::handleStartReply(QNetworkReply *reply)
{
reply->deleteLater();
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
const QString err = errorFromReply(reply, body);
setLastError(err);
emit chatStartFailed(err);
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
const QString streamId = obj.value(QStringLiteral("stream_id")).toString();
if (streamId.isEmpty()) {
const QString err = QStringLiteral("No stream_id in response");
setLastError(err);
emit chatStartFailed(err);
return;
}
const QString sid = obj.value(QStringLiteral("session_id")).toString();
if (!sid.isEmpty())
m_session.insert(QStringLiteral("session_id"), sid);
emit chatStarted(streamId);
startStream(streamId);
}
void ApiClient::cancelRun()
{
if (m_streamId.isEmpty())
return;
QUrl url = apiUrl(QStringLiteral("/api/chat/cancel"));
QUrlQuery q;
q.addQueryItem(QStringLiteral("stream_id"), m_streamId);
url.setQuery(q);
QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
});
}
// -------------------------------------------------------------------- SSE --
void ApiClient::startStream(const QString &streamId)
{
if (m_streamReply) {
m_streamReply->abort();
m_streamReply->deleteLater();
m_streamReply = nullptr;
}
m_streamId = streamId;
m_streamEndedEmitted = false;
m_parser.reset();
QUrl url = apiUrl(QStringLiteral("/api/chat/stream"));
QUrlQuery q;
q.addQueryItem(QStringLiteral("stream_id"), streamId);
url.setQuery(q);
QNetworkRequest req(url);
req.setRawHeader("Accept", "text/event-stream");
m_streamReply = m_nam.get(req);
setStreaming(true);
connect(m_streamReply, &QNetworkReply::readyRead, this, [this]() {
if (!m_streamReply)
return;
const QList<SseParser::Event> events = m_parser.feed(m_streamReply->readAll());
for (int i = 0; i < events.size(); ++i)
dispatchSseEvent(events.at(i));
});
connect(m_streamReply, &QNetworkReply::finished, this, [this]() {
QNetworkReply *reply = m_streamReply;
m_streamReply = nullptr;
if (reply) {
if (reply->error() != QNetworkReply::NoError
&& reply->error() != QNetworkReply::OperationCanceledError) {
const QString err = reply->errorString();
setLastError(err);
emit streamError(err);
}
reply->deleteLater();
}
emitStreamEndedOnce();
setStreaming(false);
});
}
void ApiClient::dispatchSseEvent(const SseParser::Event &ev)
{
const QJsonObject obj = QJsonDocument::fromJson(ev.data.toUtf8()).object();
if (ev.name == QLatin1String("token")) {
emit streamDelta(obj.value(QStringLiteral("text")).toString());
} else if (ev.name == QLatin1String("done")) {
const QVariantMap session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
if (!session.isEmpty()) {
m_session = session;
emit sessionChanged();
}
emit streamDone(session);
} else if (ev.name == QLatin1String("title")) {
const QString title = obj.value(QStringLiteral("title")).toString();
if (!title.isEmpty()) {
m_session.insert(QStringLiteral("title"), title);
emit sessionChanged();
emit streamTitle(title);
}
} else if (ev.name == QLatin1String("stream_end")) {
emitStreamEndedOnce();
}
// Altri eventi noti (context_status, metering, title_status) e sconosciuti:
// ignorati intenzionalmente in questa versione.
}
void ApiClient::emitStreamEndedOnce()
{
if (m_streamEndedEmitted)
return;
m_streamEndedEmitted = true;
emit streamEnded();
}
// ------------------------------------------------------------------- voce --
void ApiClient::transcribeFile(const QString &path)
{
setLastError(QString());
setTranscribing(true);
QFile *file = new QFile(path);
if (!file->open(QIODevice::ReadOnly)) {
file->deleteLater();
setTranscribing(false);
const QString err = QStringLiteral("Cannot open audio file");
setLastError(err);
emit transcriptionFailed(err);
return;
}
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QStringLiteral("audio/ogg")));
filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"")
.arg(QFileInfo(path).fileName())));
filePart.setBodyDevice(file);
file->setParent(multiPart);
multiPart->append(filePart);
QNetworkRequest req(apiUrl(QStringLiteral("/api/transcribe")));
QNetworkReply *reply = m_nam.post(req, multiPart);
multiPart->setParent(reply);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setTranscribing(false);
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
const QString err = errorFromReply(reply, body);
setLastError(err);
emit transcriptionFailed(err);
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
if (!obj.value(QStringLiteral("ok")).toBool()) {
QString err = obj.value(QStringLiteral("error")).toString();
if (err.isEmpty())
err = QStringLiteral("Transcription failed");
setLastError(err);
emit transcriptionFailed(err);
return;
}
emit transcriptionReady(obj.value(QStringLiteral("transcript")).toString().trimmed());
});
}
void ApiClient::speak(const QString &text)
{
const QString trimmed = text.trimmed();
if (trimmed.isEmpty())
return;
// Il server accetta max 5000 caratteri per richiesta: tronca con avviso.
QString spoken = trimmed;
if (spoken.size() > 4900)
spoken = spoken.left(4900);
setLastError(QString());
QJsonObject body;
body.insert(QStringLiteral("text"), spoken);
body.insert(QStringLiteral("voice"), m_settings->ttsVoice());
body.insert(QStringLiteral("engine"), m_settings->ttsEngine());
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
const QString err = errorFromReply(reply, body);
setLastError(err);
emit ttsFailed(err);
return;
}
const QVariant contentType = reply->header(QNetworkRequest::ContentTypeHeader);
if (!contentType.toString().startsWith(QLatin1String("audio/"))) {
QString err = QStringLiteral("TTS returned non-audio response");
const QJsonObject obj = QJsonDocument::fromJson(body).object();
const QString jsonErr = obj.value(QStringLiteral("error")).toString();
if (!jsonErr.isEmpty())
err = jsonErr;
setLastError(err);
emit ttsFailed(err);
return;
}
const QString file = cacheDir(QStringLiteral("tts"))
+ QStringLiteral("/reply-%1.mp3")
.arg(QDateTime::currentMSecsSinceEpoch());
QFile f(file);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
const QString err = QStringLiteral("Cannot write audio file");
setLastError(err);
emit ttsFailed(err);
return;
}
f.write(body);
f.close();
emit ttsReady(file);
});
}
+131
View File
@@ -0,0 +1,131 @@
#ifndef APICLIENT_H
#define APICLIENT_H
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QObject>
#include <QVariantList>
#include <QVariantMap>
#include "cookiejar.h"
#include "settings.h"
#include "sseparser.h"
class QHttpMultiPart;
// Client HTTP per la Web UI di Hermes (hermes-webui).
//
// Protocollo (vedi docs/PROTOCOL.md, validato contro il server reale):
// POST /api/auth/login {password} -> cookie di sessione
// GET /api/auth/status -> {auth_enabled, logged_in}
// GET /api/sessions -> {sessions: [...]}
// GET /api/session?session_id=ID -> {session: {...messages}}
// POST /api/session/new {workspace?} -> {session: {...}}
// POST /api/chat/start {session_id, message, ...} -> {stream_id, ...}
// GET /api/chat/stream?stream_id=ID -> SSE (text/event-stream)
// GET /api/chat/cancel?stream_id=ID -> {ok, cancelled}
// POST /api/transcribe (multipart "file") -> {ok, transcript}
// POST /api/tts {text, voice, engine} -> audio/mpeg
//
// L'autenticazione e' a cookie: il jar e' persistente (CookieJar).
class ApiClient : public QObject
{
Q_OBJECT
Q_PROPERTY(bool loggedIn READ loggedIn NOTIFY loggedInChanged)
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY(bool transcribing READ transcribing NOTIFY transcribingChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(QVariantList sessions READ sessions NOTIFY sessionsChanged)
Q_PROPERTY(QVariantMap session READ session NOTIFY sessionChanged)
public:
explicit ApiClient(Settings *settings, QObject *parent = nullptr);
bool loggedIn() const { return m_loggedIn; }
bool busy() const { return m_busy; }
bool streaming() const { return m_streaming; }
bool transcribing() const { return m_transcribing; }
QString lastError() const { return m_lastError; }
QVariantList sessions() const { return m_sessions; }
QVariantMap session() const { return m_session; }
// Avvio: verifica lo stato auth (cookie persistito o auth disabilitata).
Q_INVOKABLE void checkAuth();
Q_INVOKABLE void login(const QString &password);
Q_INVOKABLE void logout();
Q_INVOKABLE void refreshSessions();
Q_INVOKABLE void openSession(const QString &id);
Q_INVOKABLE void newSession();
// Avvia un turno e collega automaticamente lo stream SSE.
Q_INVOKABLE void startChat(const QString &sessionId, const QString &message);
Q_INVOKABLE void cancelRun();
// Voce: trascrizione di un file audio (multipart) e sintesi TTS.
Q_INVOKABLE void transcribeFile(const QString &path);
Q_INVOKABLE void speak(const QString &text);
Q_INVOKABLE void clearError();
signals:
void loggedInChanged();
void busyChanged();
void streamingChanged();
void transcribingChanged();
void lastErrorChanged();
void sessionsChanged();
void sessionChanged();
void authChecked(bool loggedIn);
void sessionOpened(); // openSession/newSession completata
void chatStarted(const QString &streamId);
void chatStartFailed(const QString &error);
void streamDelta(const QString &text);
void streamDone(const QVariantMap &session);
void streamTitle(const QString &title);
void streamEnded();
void streamError(const QString &error);
void transcriptionReady(const QString &text);
void transcriptionFailed(const QString &error);
void ttsReady(const QString &filePath);
void ttsFailed(const QString &error);
private:
void setBusy(bool on);
void setStreaming(bool on);
void setTranscribing(bool on);
void setLastError(const QString &error);
void setLoggedIn(bool on);
QUrl apiUrl(const QString &path) const;
QNetworkRequest jsonRequest(const QString &path, bool post = false) const;
QString errorFromReply(QNetworkReply *reply, const QByteArray &body) const;
QString cacheDir(const QString &sub) const;
void startStream(const QString &streamId);
void dispatchSseEvent(const SseParser::Event &ev);
void emitStreamEndedOnce();
void handleStartReply(QNetworkReply *reply);
Settings *m_settings;
QNetworkAccessManager m_nam;
CookieJar *m_jar;
bool m_loggedIn;
bool m_busy;
bool m_streaming;
bool m_transcribing;
QString m_lastError;
QVariantList m_sessions;
QVariantMap m_session;
QString m_streamId;
QNetworkReply *m_streamReply;
SseParser m_parser;
bool m_streamEndedEmitted;
};
#endif // APICLIENT_H
+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();
}
+93
View File
@@ -0,0 +1,93 @@
#ifndef CHATMODEL_H
#define CHATMODEL_H
#include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include "apiclient.h"
// Modello della conversazione corrente + orchestrazione del flusso chat.
//
// Riceve gli eventi da ApiClient e mantiene la lista messaggi per la UI:
// - apertura/nuova sessione -> reset dal JSON della sessione
// - invio -> messaggio utente ottimistico + placeholder assistente
// - streaming -> append dei delta "token"
// - done -> risincronizzazione con i messaggi finali della sessione
class ChatModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionInfoChanged)
Q_PROPERTY(QString title READ title NOTIFY sessionInfoChanged)
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(bool canSend READ canSend NOTIFY busyChanged)
Q_PROPERTY(int count READ count NOTIFY rowCountChanged)
public:
enum Roles {
RoleRole = Qt::UserRole + 1,
ContentRole,
StreamingRole,
TimestampRole
};
explicit ChatModel(ApiClient *api, QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
QString sessionId() const { return m_sessionId; }
QString title() const { return m_title; }
bool busy() const { return m_busy; }
bool canSend() const { return !m_busy && !m_sessionId.isEmpty(); }
int count() const { return m_msgs.size(); }
Q_INVOKABLE void openSession(const QString &id);
Q_INVOKABLE void newSession();
Q_INVOKABLE void send(const QString &text);
Q_INVOKABLE void cancel();
Q_INVOKABLE void resync();
Q_INVOKABLE QString lastAssistantText() const;
signals:
void sessionInfoChanged();
void busyChanged();
void rowCountChanged();
void streamTick(); // ogni delta di streaming (per l'auto-scroll)
void assistantFinished(); // risposta completata (per auto-read TTS)
void notice(const QString &text); // avvisi non bloccanti per la UI
private:
struct Msg {
QString role;
QString content;
bool streaming;
double ts;
};
void setFromMessages(const QVariantList &messages);
void appendMessage(const QString &role, const QString &content, bool streaming);
void beginAssistant();
void setBusy(bool on);
void onSessionOpened();
void onChatStarted(const QString &streamId);
void onChatStartFailed(const QString &error);
void onStreamDelta(const QString &text);
void onStreamDone(const QVariantMap &session);
void onStreamEnded();
void onStreamError(const QString &error);
ApiClient *m_api;
QList<Msg> m_msgs;
QString m_sessionId;
QString m_title;
bool m_busy;
bool m_finalizedThisRun;
};
#endif // CHATMODEL_H
+72
View File
@@ -0,0 +1,72 @@
#include "cookiejar.h"
#include <QFile>
#include <QDir>
#include <QFileInfo>
#include <QNetworkCookie>
CookieJar::CookieJar(QObject *parent)
: QNetworkCookieJar(parent)
{
}
void CookieJar::setFilePath(const QString &path)
{
m_path = path;
}
QString CookieJar::filePath() const
{
return m_path;
}
bool CookieJar::load()
{
if (m_path.isEmpty())
return false;
QFile f(m_path);
if (!f.open(QIODevice::ReadOnly))
return false;
const QByteArray data = f.readAll();
f.close();
const QList<QNetworkCookie> cookies = QNetworkCookie::parseCookies(data);
if (cookies.isEmpty())
return false;
setAllCookies(cookies);
return true;
}
bool CookieJar::save() const
{
if (m_path.isEmpty())
return false;
const QFileInfo info(m_path);
QDir().mkpath(info.absolutePath());
QFile f(m_path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
return false;
// Permessi solo proprietario PRIMA di scrivere il contenuto.
f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
const QList<QNetworkCookie> cookies = allCookies();
for (int i = 0; i < cookies.size(); ++i) {
f.write(cookies.at(i).toRawForm(QNetworkCookie::Full));
f.write("\n");
}
f.close();
return true;
}
void CookieJar::clear()
{
setAllCookies(QList<QNetworkCookie>());
if (!m_path.isEmpty())
QFile::remove(m_path);
}
bool CookieJar::setCookiesFromUrl(const QList<QNetworkCookie> &cookieList, const QUrl &url)
{
const bool changed = QNetworkCookieJar::setCookiesFromUrl(cookieList, url);
if (changed)
save();
return changed;
}
+31
View File
@@ -0,0 +1,31 @@
#ifndef COOKIEJAR_H
#define COOKIEJAR_H
#include <QNetworkCookie>
#include <QNetworkCookieJar>
#include <QString>
// Cookie jar persistente su file (0600).
// Il cookie di sessione del WebUI di Hermes e' una credenziale: vive solo in
// ~/.config/harbour/hermes/cookies.dat con permessi del solo proprietario.
// Il salvataggio avviene automaticamente a ogni inserimento di cookie.
class CookieJar : public QNetworkCookieJar
{
public:
explicit CookieJar(QObject *parent = nullptr);
void setFilePath(const QString &path);
QString filePath() const;
bool load();
bool save() const;
void clear();
protected:
bool setCookiesFromUrl(const QList<QNetworkCookie> &cookieList, const QUrl &url) override;
private:
QString m_path;
};
#endif // COOKIEJAR_H
+48
View File
@@ -0,0 +1,48 @@
#include <QGuiApplication>
#include <QQmlContext>
#include <QQuickView>
#include <QDebug>
#include <sailfishapp.h>
#include "apiclient.h"
#include "chatmodel.h"
#include "player.h"
#include "recorder.h"
#include "settings.h"
Q_DECL_EXPORT int main(int argc, char *argv[])
{
QGuiApplication *app = SailfishApp::application(argc, argv);
// Identita' allineata al profilo X-Sailjail del desktop file:
// AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox).
app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("hermes"));
app->setApplicationVersion(QStringLiteral("0.1.0"));
qDebug() << "harbour-hermes v0.1.0 build" << __DATE__ << __TIME__;
Settings settings;
ApiClient api(&settings);
ChatModel chat(&api);
Recorder recorder;
Player player;
// Flusso voce: registrazione -> trascrizione; TTS pronto -> riproduzione.
QObject::connect(&recorder, &Recorder::finished, &api, &ApiClient::transcribeFile);
QObject::connect(&api, &ApiClient::ttsReady, &player, &Player::play);
QQuickView *view = SailfishApp::createView();
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
view->rootContext()->setContextProperty(QStringLiteral("api"), &api);
view->rootContext()->setContextProperty(QStringLiteral("chat"), &chat);
view->rootContext()->setContextProperty(QStringLiteral("recorder"), &recorder);
view->rootContext()->setContextProperty(QStringLiteral("player"), &player);
view->rootContext()->setContextProperty(QStringLiteral("appVersion"),
QCoreApplication::applicationVersion());
view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-hermes.qml")));
view->show();
return app->exec();
}
+39
View File
@@ -0,0 +1,39 @@
#include "player.h"
#include <QMediaContent>
#include <QUrl>
Player::Player(QObject *parent)
: QObject(parent)
{
connect(&m_player, &QMediaPlayer::stateChanged, this,
[this](QMediaPlayer::State) { emit playingChanged(); });
connect(&m_player, &QMediaPlayer::mediaStatusChanged, this,
[this](QMediaPlayer::MediaStatus status) {
if (status == QMediaPlayer::EndOfMedia
|| status == QMediaPlayer::InvalidMedia) {
emit playingChanged();
emit finished();
}
});
}
bool Player::playing() const
{
return m_player.state() == QMediaPlayer::PlayingState;
}
void Player::play(const QString &path)
{
stop();
m_player.setMedia(QMediaContent(QUrl::fromLocalFile(path)));
m_player.play();
emit playingChanged();
}
void Player::stop()
{
if (m_player.state() != QMediaPlayer::StoppedState)
m_player.stop();
emit playingChanged();
}
+31
View File
@@ -0,0 +1,31 @@
#ifndef PLAYER_H
#define PLAYER_H
#include <QMediaPlayer>
#include <QObject>
#include <QString>
// Riproduttore audio per le risposte TTS (QMediaPlayer, mp3 dal server).
// L'output passa da PulseAudio; il volume segue il sistema.
class Player : public QObject
{
Q_OBJECT
Q_PROPERTY(bool playing READ playing NOTIFY playingChanged)
public:
explicit Player(QObject *parent = nullptr);
bool playing() const;
Q_INVOKABLE void play(const QString &path);
Q_INVOKABLE void stop();
signals:
void playingChanged();
void finished();
private:
QMediaPlayer m_player;
};
#endif // PLAYER_H
+143
View File
@@ -0,0 +1,143 @@
#include "recorder.h"
#include <QAudioEncoderSettings>
#include <QAudioRecorder>
#include <QDateTime>
#include <QDir>
#include <QStandardPaths>
#include <QUrl>
#include <QVideoEncoderSettings>
namespace {
// Container preferito: OGG; ripiego WAV.
QString chooseContainer(const QStringList &containers)
{
if (containers.contains(QStringLiteral("audio/ogg")))
return QStringLiteral("audio/ogg");
if (containers.contains(QStringLiteral("audio/x-wav")))
return QStringLiteral("audio/x-wav");
return containers.isEmpty() ? QString() : containers.first();
}
// Codec preferito: Opus, poi Vorbis, poi PCM.
QString chooseCodec(const QStringList &codecs)
{
if (codecs.contains(QStringLiteral("audio/opus")))
return QStringLiteral("audio/opus");
if (codecs.contains(QStringLiteral("audio/vorbis")))
return QStringLiteral("audio/vorbis");
if (codecs.contains(QStringLiteral("audio/pcm")))
return QStringLiteral("audio/pcm");
return codecs.isEmpty() ? QString() : codecs.first();
}
} // namespace
Recorder::Recorder(QObject *parent)
: QObject(parent)
, m_rec(new QAudioRecorder(this))
, m_stopping(false)
, m_fallbackTried(false)
{
connect(m_rec,
static_cast<void (QAudioRecorder::*)(QMediaRecorder::State)>(&QAudioRecorder::stateChanged),
this, [this](QMediaRecorder::State) { onStateChanged(); });
connect(m_rec,
static_cast<void (QAudioRecorder::*)(QMediaRecorder::Error)>(&QAudioRecorder::error),
this, [this](QMediaRecorder::Error) { onError(); });
m_tick.setInterval(250);
connect(&m_tick, &QTimer::timeout, this, [this]() { emit durationChanged(); });
}
bool Recorder::recording() const
{
return m_rec->state() == QMediaRecorder::RecordingState;
}
int Recorder::durationMs() const
{
return static_cast<int>(m_rec->duration());
}
bool Recorder::start()
{
if (recording())
return true;
const QString container = chooseContainer(m_rec->supportedContainers());
const QString codec = chooseCodec(m_rec->supportedAudioCodecs());
if (container.isEmpty() || codec.isEmpty()) {
emit failed(QStringLiteral("No audio format available"));
return false;
}
m_fallbackTried = false;
return beginRecording(container, codec);
}
void Recorder::stop()
{
if (!recording())
return;
m_stopping = true;
m_rec->stop();
}
bool Recorder::beginRecording(const QString &container, const QString &codec)
{
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
const QString dir = base + QStringLiteral("/rec");
QDir().mkpath(dir);
const QString ext = container == QLatin1String("audio/x-wav") ? QStringLiteral(".wav")
: QStringLiteral(".ogg");
m_path = dir + QStringLiteral("/rec-%1%2")
.arg(QDateTime::currentMSecsSinceEpoch())
.arg(ext);
m_container = container;
QAudioEncoderSettings enc;
enc.setCodec(codec);
enc.setSampleRate(16000);
enc.setChannelCount(1);
enc.setQuality(QMultimedia::NormalQuality);
m_rec->setEncodingSettings(enc, QVideoEncoderSettings(), container);
m_rec->setOutputLocation(QUrl::fromLocalFile(m_path));
m_stopping = false;
m_rec->record();
return true;
}
void Recorder::onStateChanged()
{
const bool rec = recording();
if (rec)
m_tick.start();
else
m_tick.stop();
emit recordingChanged();
emit durationChanged();
if (!rec && m_stopping) {
m_stopping = false;
if (m_path.isEmpty())
return;
emit finished(m_path);
}
}
void Recorder::onError()
{
// Primo tentativo fallito (es. codec non disponibile nel target):
// un solo ritentativo con WAV/PCM, poi si arrende.
if (!m_fallbackTried && !m_path.isEmpty()) {
m_fallbackTried = true;
m_rec->stop();
if (beginRecording(QStringLiteral("audio/x-wav"), QStringLiteral("audio/pcm")))
return;
}
m_tick.stop();
emit recordingChanged();
emit failed(m_rec->errorString());
}
+51
View File
@@ -0,0 +1,51 @@
#ifndef RECORDER_H
#define RECORDER_H
#include <QObject>
#include <QString>
#include <QStringList>
#include <QTimer>
class QAudioRecorder;
// Registratore vocale (QAudioRecorder) con fallback di formato:
// container/codec preferiti OGG/Opus, poi OGG/Vorbis, poi WAV/PCM.
// Se il primo tentativo fallisce a runtime (codec assente nel target),
// riprova una volta con WAV/PCM.
// I file finiscono in <AppData>/rec/ (zona dati della sandbox SailJail).
// Il permesso Microphone e' dichiarato nel profilo X-Sailjail del desktop file.
class Recorder : public QObject
{
Q_OBJECT
Q_PROPERTY(bool recording READ recording NOTIFY recordingChanged)
Q_PROPERTY(int durationMs READ durationMs NOTIFY durationChanged)
public:
explicit Recorder(QObject *parent = nullptr);
bool recording() const;
int durationMs() const;
Q_INVOKABLE bool start();
Q_INVOKABLE void stop();
signals:
void recordingChanged();
void durationChanged();
void finished(const QString &path); // file pronto per la trascrizione
void failed(const QString &error);
private:
void onStateChanged();
void onError();
bool beginRecording(const QString &container, const QString &codec);
QAudioRecorder *m_rec;
QTimer m_tick;
QString m_path;
QString m_container;
bool m_stopping;
bool m_fallbackTried;
};
#endif // RECORDER_H
+84
View File
@@ -0,0 +1,84 @@
#include "settings.h"
// Default: l'istanza pubblica del WebUI usa questo host (HTTPS, cert valido).
// L'utente puo' cambiarlo dalle impostazioni (LAN, VPN, altro dominio).
static const char *kDefaultBaseUrl = "https://hermes.hackatoniclife.com";
static const char *kDefaultTtsVoice = "it-IT-ElsaNeural";
static const char *kDefaultTtsEngine = "edge";
Settings::Settings(QObject *parent)
: QObject(parent)
{
}
QString Settings::baseUrl() const
{
QString v = m_settings.value(QStringLiteral("baseUrl"), QString::fromLatin1(kDefaultBaseUrl)).toString();
// Normalizza: niente slash finale (componiamo gli URL con path assoluti).
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
return v;
}
void Settings::setBaseUrl(const QString &url)
{
QString v = url.trimmed();
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
if (v == baseUrl())
return;
m_settings.setValue(QStringLiteral("baseUrl"), v);
emit baseUrlChanged();
}
QString Settings::ttsVoice() const
{
return m_settings.value(QStringLiteral("ttsVoice"), QString::fromLatin1(kDefaultTtsVoice)).toString();
}
void Settings::setTtsVoice(const QString &voice)
{
if (voice == ttsVoice())
return;
m_settings.setValue(QStringLiteral("ttsVoice"), voice);
emit ttsVoiceChanged();
}
QString Settings::ttsEngine() const
{
return m_settings.value(QStringLiteral("ttsEngine"), QString::fromLatin1(kDefaultTtsEngine)).toString();
}
void Settings::setTtsEngine(const QString &engine)
{
if (engine == ttsEngine())
return;
m_settings.setValue(QStringLiteral("ttsEngine"), engine);
emit ttsEngineChanged();
}
bool Settings::ttsAutoRead() const
{
return m_settings.value(QStringLiteral("ttsAutoRead"), false).toBool();
}
void Settings::setTtsAutoRead(bool on)
{
if (on == ttsAutoRead())
return;
m_settings.setValue(QStringLiteral("ttsAutoRead"), on);
emit ttsAutoReadChanged();
}
QString Settings::defaultWorkspace() const
{
return m_settings.value(QStringLiteral("defaultWorkspace"), QString()).toString();
}
void Settings::setDefaultWorkspace(const QString &ws)
{
if (ws == defaultWorkspace())
return;
m_settings.setValue(QStringLiteral("defaultWorkspace"), ws);
emit defaultWorkspaceChanged();
}
+54
View File
@@ -0,0 +1,54 @@
#ifndef SETTINGS_H
#define SETTINGS_H
#include <QObject>
#include <QSettings>
#include <QString>
// Impostazioni persistenti dell'app.
// QSettings con OrganizationName "harbour" / ApplicationName "hermes" (impostati
// in main.cpp, allineati al profilo X-Sailjail del desktop file): file in
// ~/.config/harbour/hermes.conf (zona persistente della sandbox).
//
// La password NON viene mai salvata: l'autenticazione si basa sul cookie di
// sessione persistito in modo protetto (vedi CookieJar).
class Settings : public QObject
{
Q_OBJECT
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
Q_PROPERTY(QString ttsVoice READ ttsVoice WRITE setTtsVoice NOTIFY ttsVoiceChanged)
Q_PROPERTY(QString ttsEngine READ ttsEngine WRITE setTtsEngine NOTIFY ttsEngineChanged)
Q_PROPERTY(bool ttsAutoRead READ ttsAutoRead WRITE setTtsAutoRead NOTIFY ttsAutoReadChanged)
Q_PROPERTY(QString defaultWorkspace READ defaultWorkspace WRITE setDefaultWorkspace
NOTIFY defaultWorkspaceChanged)
public:
explicit Settings(QObject *parent = nullptr);
QString baseUrl() const;
void setBaseUrl(const QString &url);
QString ttsVoice() const;
void setTtsVoice(const QString &voice);
QString ttsEngine() const;
void setTtsEngine(const QString &engine);
bool ttsAutoRead() const;
void setTtsAutoRead(bool on);
QString defaultWorkspace() const;
void setDefaultWorkspace(const QString &ws);
signals:
void baseUrlChanged();
void ttsVoiceChanged();
void ttsEngineChanged();
void ttsAutoReadChanged();
void defaultWorkspaceChanged();
private:
QSettings m_settings;
};
#endif // SETTINGS_H
+59
View File
@@ -0,0 +1,59 @@
#include "sseparser.h"
SseParser::SseParser()
{
}
QList<SseParser::Event> SseParser::feed(const QByteArray &chunk)
{
m_buffer += chunk;
// Normalizza le terminazioni di riga (il server usa \n, ma per sicurezza).
if (m_buffer.contains('\r'))
m_buffer.replace("\r\n", "\n");
QList<Event> events;
int idx = -1;
while ((idx = m_buffer.indexOf("\n\n")) >= 0) {
const QByteArray block = m_buffer.left(idx);
m_buffer.remove(0, idx + 2);
Event ev;
bool hasData = false;
const QList<QByteArray> lines = block.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QByteArray &line = lines.at(i);
if (line.isEmpty() || line.startsWith(':'))
continue; // riga vuota o commento/keepalive
if (line.startsWith("event:")) {
ev.name = QString::fromUtf8(line.mid(6)).trimmed();
} else if (line.startsWith("data:")) {
QString d = QString::fromUtf8(line.mid(5));
if (d.startsWith(QLatin1Char(' ')))
d.remove(0, 1);
if (hasData)
ev.data += QLatin1Char('\n');
ev.data += d;
hasData = true;
} else if (line.startsWith("id:")) {
ev.id = QString::fromUtf8(line.mid(3)).trimmed();
}
}
// Blocco di soli commenti/keepalive: niente da consegnare.
if (!hasData)
continue;
if (ev.name.isEmpty())
ev.name = QStringLiteral("message");
events.append(ev);
}
return events;
}
void SseParser::reset()
{
m_buffer.clear();
}
QByteArray SseParser::pending() const
{
return m_buffer;
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef SSEPARSER_H
#define SSEPARSER_H
#include <QByteArray>
#include <QList>
#include <QString>
// Parser incrementale di Server-Sent Events (text/event-stream).
//
// Il WebUI di Hermes emette eventi nella forma:
// id: <stream_id>:<seq>
// event: token
// data: {"text": "..."}
// <riga vuota>
// piu' commenti di keepalive (": heartbeat") da ignorare.
//
// Logica pura, senza dipendenze da rete: alimentato con i chunk grezzi,
// restituisce gli eventi completi. Testabile in locale (tests/sse_test.cpp).
class SseParser
{
public:
struct Event {
QString name; // campo "event:" (vuoto => "message" SSE standard)
QString data; // campi "data:" concatenati con "\n"
QString id; // campo "id:" (cursore per replay)
};
SseParser();
// Aggiunge un chunk e restituisce tutti gli eventi completati.
QList<Event> feed(const QByteArray &chunk);
void reset();
QByteArray pending() const;
private:
QByteArray m_buffer;
};
#endif // SSEPARSER_H