#include "apiclient.h" #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); } // ---------------------------------------------------------------- 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"))); 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) { 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)); 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_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)); 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_sessionsModel->setSessions(arr.toVariantList()); emit sessionsChanged(); }); } int ApiClient::sessionsCount() const { return m_sessionsModel->rowCount(); } 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 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); 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)); 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); }); }