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);
});
}