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
660 lines
23 KiB
C++
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);
|
|
});
|
|
}
|