Files
harbour-hermes/src/apiclient.cpp
T
kaneda c5c4049030 fix(api): watchdog con timeout su tutte le richieste — mai piu' attese infinite (v0.5.1)
Sintomo sul device: l'app restava sullo splash in caricamento per sempre.
Causa: QNetworkAccessManager non applica timeout — una richiesta che non
riceve risposta (rete del telefono, server occupato) lasciava la UI in
attesa infinita, senza errori visibili.

- ApiClient::armTimeout(): watchdog per reply (abort -> normale percorso
  d'errore del chiamante). Applicato a: checkAuth (12 s), login (20 s),
  refreshSessions e openSession (60 s), newSession/chat-start/switchProfile
  (20-30 s), fetchProfiles (20 s), transcribe (300 s), tts (120 s).
  Nessun watchdog sullo stream SSE (longevo per design).
- checkAuth: imposta lastError anche in errore (messaggio tradotto
  "Server not reachable (timeout)") e logga indirizzo ed esito
- refreshSessions: log della durata (misurato sul probe: il primo fetch
  dopo un cambio profilo puo' prendere 11-16 s lato server, anche su DB
  minuscolo — l'app ora lo gestisce senza sembrare bloccata)
- SplashPage: rete di sicurezza (se authChecked non arriva entro 15 s si
  va comunque al login) + log di instradamento
- i18n: nuova stringa tradotta it/fr/de
2026-09-13 07:37:12 +02:00

660 lines
23 KiB
C++

#include "apiclient.h"
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
#include <QTimer>
#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_sessionsModel(new SessionsModel(this))
, 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);
// Il profilo selezionato (impostazione app) e' esposto anche da ApiClient.
connect(m_settings, &Settings::profileChanged, this, &ApiClient::profileChanged);
}
// QNetworkAccessManager non applica timeout: senza watchdog una richiesta
// senza risposta (rete assente, server occupato) lascerebbe la UI in attesa
// per sempre. armTimeout aborta la reply dopo N secondi: poi si attiva il
// normale percorso d'errore del chiamante (messaggio visibile, stato resettato).
void ApiClient::armTimeout(QNetworkReply *reply, int seconds)
{
QTimer *timer = new QTimer(reply);
timer->setSingleShot(true);
timer->setInterval(seconds * 1000);
connect(timer, &QTimer::timeout, reply, [reply, seconds]() {
if (reply->isRunning()) {
qDebug() << "[api] timeout dopo" << seconds << "s su" << reply->url().path();
reply->abort();
}
});
connect(reply, &QNetworkReply::finished, timer, &QTimer::stop);
timer->start();
}
// ---------------------------------------------------------------- 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 tr("Authentication required");
if (status > 0)
return tr("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()
{
// Senza indirizzo configurato non c'e' nulla da verificare: si parte
// dalla pagina di login, che lo richiede.
if (m_settings->baseUrl().isEmpty()) {
setLoggedIn(false);
emit authChecked(false);
return;
}
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/auth/status")));
armTimeout(reply, 12); // il controllo d'avvio deve risolversi, sempre
qDebug() << "[api] checkAuth ->" << m_settings->baseUrl();
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
const bool wasTimeout = (reply->error() == QNetworkReply::OperationCanceledError);
setLastError(wasTimeout ? tr("Server not reachable (timeout)")
: errorFromReply(reply, body));
qDebug() << "[api] checkAuth FALLITO:" << m_lastError;
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);
}
qDebug() << "[api] checkAuth:" << (m_loggedIn ? "sessione valida" : "serve il login");
emit authChecked(m_loggedIn);
});
}
void ApiClient::login(const QString &password)
{
if (m_settings->baseUrl().isEmpty()) {
setLastError(tr("Server address not configured"));
return;
}
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));
armTimeout(reply, 20);
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);
// Riapplica il profilo scelto dall'utente: il nuovo cookie di sessione
// va rilegato al profilo (switchProfile aggiorna anche le sessioni).
if (!m_settings->profile().isEmpty())
switchProfile(m_settings->profile());
else
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_sessionsModel->setSessions(QVariantList());
emit sessionsChanged();
m_session.clear();
emit sessionChanged();
}
// --------------------------------------------------------------- sessioni --
void ApiClient::refreshSessions()
{
setBusy(true);
// Stessi parametri della sidebar del WebUI: solo sessioni WebUI visibili
// (senza parametri arriverebbero anche le CLI/cron e le nascoste).
QUrl url = apiUrl(QStringLiteral("/api/sessions"));
QUrlQuery q;
q.addQueryItem(QStringLiteral("sidebar_source"), QStringLiteral("webui"));
q.addQueryItem(QStringLiteral("exclude_hidden"), QStringLiteral("1"));
url.setQuery(q);
QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
armTimeout(reply, 60); // il primo fetch dopo un cambio profilo puo' essere lento
const qint64 t0 = QDateTime::currentMSecsSinceEpoch();
connect(reply, &QNetworkReply::finished, this, [this, reply, t0]() {
reply->deleteLater();
setBusy(false);
qDebug() << "[api] sessions in" << (QDateTime::currentMSecsSinceEpoch() - t0) << "ms";
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_sessionsModel->setSessions(arr.toVariantList());
emit sessionsChanged();
});
}
int ApiClient::sessionsCount() const
{
return m_sessionsModel->rowCount();
}
QString ApiClient::profile() const
{
return m_settings->profile();
}
void ApiClient::fetchProfiles()
{
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/profiles")));
armTimeout(reply, 20);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const QByteArray data = reply->readAll();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status < 200 || status >= 300) {
qDebug() << "[api] fetchProfiles HTTP" << status;
return;
}
const QJsonArray arr = QJsonDocument::fromJson(data).object()
.value(QStringLiteral("profiles")).toArray();
QStringList names;
for (int i = 0; i < arr.size(); ++i) {
const QString n = arr.at(i).toObject().value(QStringLiteral("name")).toString();
if (!n.isEmpty())
names << n;
}
qDebug() << "[api] profili disponibili:" << names;
if (names != m_profiles) {
m_profiles = names;
emit profilesChanged();
}
});
}
void ApiClient::switchProfile(const QString &name)
{
const QString target = name.trimmed();
if (target.isEmpty()) {
// "(attivo sul server)": rimuove il cookie di profilo e torna a
// seguire il profilo attivo del server.
m_jar->removeCookiesByName(QStringLiteral("hermes_profile"));
m_settings->setProfile(QString());
emit profileSwitched(QString());
refreshSessions();
return;
}
QJsonObject body;
body.insert(QStringLiteral("name"), target);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/profile/switch")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
armTimeout(reply, 20);
connect(reply, &QNetworkReply::finished, this, [this, reply, target]() {
reply->deleteLater();
const QByteArray data = reply->readAll();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status >= 200 && status < 300) {
// Il Set-Cookie firmato (hermes_profile) viene salvato dal jar:
// da qui in poi le richieste dell'app sono profilate lato server.
m_settings->setProfile(target);
qDebug() << "[api] profilo selezionato:" << target;
emit profileSwitched(target);
refreshSessions();
} else {
const QString err = errorFromReply(reply, data);
qDebug() << "[api] switch profilo fallito:" << err;
emit profileSwitchFailed(err);
}
});
}
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));
armTimeout(reply, 60);
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));
armTimeout(reply, 30);
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));
armTimeout(reply, 30);
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;
// Content-Type coerente col formato reale (ogg = dettatura manuale,
// wav = modalita' dialogo, ...).
QString mime = QStringLiteral("audio/ogg");
const QString suffix = QFileInfo(path).suffix().toLower();
if (suffix == QLatin1String("wav"))
mime = QStringLiteral("audio/wav");
else if (suffix == QLatin1String("mp3"))
mime = QStringLiteral("audio/mpeg");
else if (suffix == QLatin1String("m4a"))
mime = QStringLiteral("audio/mp4");
else if (suffix == QLatin1String("opus"))
mime = QStringLiteral("audio/opus");
filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(mime));
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);
armTimeout(reply, 300); // la trascrizione puo' richiedere tempo
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);
// Voce/engine vuoti = default del server: i parametri si omettono.
if (!m_settings->ttsVoice().isEmpty())
body.insert(QStringLiteral("voice"), m_settings->ttsVoice());
if (!m_settings->ttsEngine().isEmpty())
body.insert(QStringLiteral("engine"), m_settings->ttsEngine());
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
armTimeout(reply, 120);
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);
});
}