#include "apiclient.h" #include #include #include #include #include #include #include #include #include #include #include #include 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 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); }); }