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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user