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);
|
||||
});
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
#ifndef APICLIENT_H
|
||||
#define APICLIENT_H
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include "cookiejar.h"
|
||||
#include "settings.h"
|
||||
#include "sseparser.h"
|
||||
|
||||
class QHttpMultiPart;
|
||||
|
||||
// Client HTTP per la Web UI di Hermes (hermes-webui).
|
||||
//
|
||||
// Protocollo (vedi docs/PROTOCOL.md, validato contro il server reale):
|
||||
// POST /api/auth/login {password} -> cookie di sessione
|
||||
// GET /api/auth/status -> {auth_enabled, logged_in}
|
||||
// GET /api/sessions -> {sessions: [...]}
|
||||
// GET /api/session?session_id=ID -> {session: {...messages}}
|
||||
// POST /api/session/new {workspace?} -> {session: {...}}
|
||||
// POST /api/chat/start {session_id, message, ...} -> {stream_id, ...}
|
||||
// GET /api/chat/stream?stream_id=ID -> SSE (text/event-stream)
|
||||
// GET /api/chat/cancel?stream_id=ID -> {ok, cancelled}
|
||||
// POST /api/transcribe (multipart "file") -> {ok, transcript}
|
||||
// POST /api/tts {text, voice, engine} -> audio/mpeg
|
||||
//
|
||||
// L'autenticazione e' a cookie: il jar e' persistente (CookieJar).
|
||||
class ApiClient : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool loggedIn READ loggedIn NOTIFY loggedInChanged)
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
|
||||
Q_PROPERTY(bool transcribing READ transcribing NOTIFY transcribingChanged)
|
||||
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
||||
Q_PROPERTY(QVariantList sessions READ sessions NOTIFY sessionsChanged)
|
||||
Q_PROPERTY(QVariantMap session READ session NOTIFY sessionChanged)
|
||||
|
||||
public:
|
||||
explicit ApiClient(Settings *settings, QObject *parent = nullptr);
|
||||
|
||||
bool loggedIn() const { return m_loggedIn; }
|
||||
bool busy() const { return m_busy; }
|
||||
bool streaming() const { return m_streaming; }
|
||||
bool transcribing() const { return m_transcribing; }
|
||||
QString lastError() const { return m_lastError; }
|
||||
QVariantList sessions() const { return m_sessions; }
|
||||
QVariantMap session() const { return m_session; }
|
||||
|
||||
// Avvio: verifica lo stato auth (cookie persistito o auth disabilitata).
|
||||
Q_INVOKABLE void checkAuth();
|
||||
Q_INVOKABLE void login(const QString &password);
|
||||
Q_INVOKABLE void logout();
|
||||
|
||||
Q_INVOKABLE void refreshSessions();
|
||||
Q_INVOKABLE void openSession(const QString &id);
|
||||
Q_INVOKABLE void newSession();
|
||||
|
||||
// Avvia un turno e collega automaticamente lo stream SSE.
|
||||
Q_INVOKABLE void startChat(const QString &sessionId, const QString &message);
|
||||
Q_INVOKABLE void cancelRun();
|
||||
|
||||
// Voce: trascrizione di un file audio (multipart) e sintesi TTS.
|
||||
Q_INVOKABLE void transcribeFile(const QString &path);
|
||||
Q_INVOKABLE void speak(const QString &text);
|
||||
|
||||
Q_INVOKABLE void clearError();
|
||||
|
||||
signals:
|
||||
void loggedInChanged();
|
||||
void busyChanged();
|
||||
void streamingChanged();
|
||||
void transcribingChanged();
|
||||
void lastErrorChanged();
|
||||
void sessionsChanged();
|
||||
void sessionChanged();
|
||||
|
||||
void authChecked(bool loggedIn);
|
||||
void sessionOpened(); // openSession/newSession completata
|
||||
void chatStarted(const QString &streamId);
|
||||
void chatStartFailed(const QString &error);
|
||||
void streamDelta(const QString &text);
|
||||
void streamDone(const QVariantMap &session);
|
||||
void streamTitle(const QString &title);
|
||||
void streamEnded();
|
||||
void streamError(const QString &error);
|
||||
void transcriptionReady(const QString &text);
|
||||
void transcriptionFailed(const QString &error);
|
||||
void ttsReady(const QString &filePath);
|
||||
void ttsFailed(const QString &error);
|
||||
|
||||
private:
|
||||
void setBusy(bool on);
|
||||
void setStreaming(bool on);
|
||||
void setTranscribing(bool on);
|
||||
void setLastError(const QString &error);
|
||||
void setLoggedIn(bool on);
|
||||
|
||||
QUrl apiUrl(const QString &path) const;
|
||||
QNetworkRequest jsonRequest(const QString &path, bool post = false) const;
|
||||
QString errorFromReply(QNetworkReply *reply, const QByteArray &body) const;
|
||||
QString cacheDir(const QString &sub) const;
|
||||
|
||||
void startStream(const QString &streamId);
|
||||
void dispatchSseEvent(const SseParser::Event &ev);
|
||||
void emitStreamEndedOnce();
|
||||
void handleStartReply(QNetworkReply *reply);
|
||||
|
||||
Settings *m_settings;
|
||||
QNetworkAccessManager m_nam;
|
||||
CookieJar *m_jar;
|
||||
|
||||
bool m_loggedIn;
|
||||
bool m_busy;
|
||||
bool m_streaming;
|
||||
bool m_transcribing;
|
||||
QString m_lastError;
|
||||
|
||||
QVariantList m_sessions;
|
||||
QVariantMap m_session;
|
||||
|
||||
QString m_streamId;
|
||||
QNetworkReply *m_streamReply;
|
||||
SseParser m_parser;
|
||||
bool m_streamEndedEmitted;
|
||||
};
|
||||
|
||||
#endif // APICLIENT_H
|
||||
@@ -0,0 +1,252 @@
|
||||
#include "chatmodel.h"
|
||||
|
||||
#include <QTimer>
|
||||
|
||||
ChatModel::ChatModel(ApiClient *api, QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
, m_api(api)
|
||||
, m_busy(false)
|
||||
, m_finalizedThisRun(false)
|
||||
{
|
||||
connect(m_api, &ApiClient::sessionOpened, this, &ChatModel::onSessionOpened);
|
||||
connect(m_api, &ApiClient::chatStarted, this, &ChatModel::onChatStarted);
|
||||
connect(m_api, &ApiClient::chatStartFailed, this, &ChatModel::onChatStartFailed);
|
||||
connect(m_api, &ApiClient::streamDelta, this, &ChatModel::onStreamDelta);
|
||||
connect(m_api, &ApiClient::streamDone, this, &ChatModel::onStreamDone);
|
||||
connect(m_api, &ApiClient::streamEnded, this, &ChatModel::onStreamEnded);
|
||||
connect(m_api, &ApiClient::streamError, this, &ChatModel::onStreamError);
|
||||
}
|
||||
|
||||
int ChatModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid())
|
||||
return 0;
|
||||
return m_msgs.size();
|
||||
}
|
||||
|
||||
QVariant ChatModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= m_msgs.size())
|
||||
return QVariant();
|
||||
const Msg &m = m_msgs.at(index.row());
|
||||
switch (role) {
|
||||
case RoleRole:
|
||||
return m.role;
|
||||
case ContentRole:
|
||||
return m.content;
|
||||
case StreamingRole:
|
||||
return m.streaming;
|
||||
case TimestampRole:
|
||||
return m.ts;
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> ChatModel::roleNames() const
|
||||
{
|
||||
QHash<int, QByteArray> roles;
|
||||
roles.insert(RoleRole, "role");
|
||||
roles.insert(ContentRole, "content");
|
||||
roles.insert(StreamingRole, "streaming");
|
||||
roles.insert(TimestampRole, "timestamp");
|
||||
return roles;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ flusso chat --
|
||||
|
||||
void ChatModel::openSession(const QString &id)
|
||||
{
|
||||
m_sessionId = id;
|
||||
m_title.clear();
|
||||
setFromMessages(QVariantList());
|
||||
emit sessionInfoChanged();
|
||||
m_api->openSession(id);
|
||||
}
|
||||
|
||||
void ChatModel::newSession()
|
||||
{
|
||||
m_title.clear();
|
||||
setFromMessages(QVariantList());
|
||||
emit sessionInfoChanged();
|
||||
m_api->newSession();
|
||||
}
|
||||
|
||||
void ChatModel::resync()
|
||||
{
|
||||
if (!m_sessionId.isEmpty())
|
||||
m_api->openSession(m_sessionId);
|
||||
}
|
||||
|
||||
void ChatModel::send(const QString &text)
|
||||
{
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty() || m_sessionId.isEmpty() || m_busy)
|
||||
return;
|
||||
m_finalizedThisRun = false;
|
||||
appendMessage(QStringLiteral("user"), trimmed, false);
|
||||
beginAssistant();
|
||||
setBusy(true);
|
||||
m_api->startChat(m_sessionId, trimmed);
|
||||
}
|
||||
|
||||
void ChatModel::cancel()
|
||||
{
|
||||
m_api->cancelRun();
|
||||
}
|
||||
|
||||
QString ChatModel::lastAssistantText() const
|
||||
{
|
||||
for (int i = m_msgs.size() - 1; i >= 0; --i) {
|
||||
const Msg &m = m_msgs.at(i);
|
||||
if (m.role == QLatin1String("assistant") && !m.content.trimmed().isEmpty())
|
||||
return m.content;
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
void ChatModel::onSessionOpened()
|
||||
{
|
||||
const QVariantMap s = m_api->session();
|
||||
const QString sid = s.value(QStringLiteral("session_id")).toString();
|
||||
if (!sid.isEmpty())
|
||||
m_sessionId = sid;
|
||||
m_title = s.value(QStringLiteral("title")).toString();
|
||||
setFromMessages(s.value(QStringLiteral("messages")).toList());
|
||||
emit sessionInfoChanged();
|
||||
// Ripresa: la sessione aveva un run attivo e lo stream e' stato riagganciato.
|
||||
if (m_api->streaming()) {
|
||||
beginAssistant();
|
||||
setBusy(true);
|
||||
}
|
||||
}
|
||||
|
||||
void ChatModel::onChatStarted(const QString &streamId)
|
||||
{
|
||||
Q_UNUSED(streamId)
|
||||
if (m_msgs.isEmpty() || m_msgs.last().role != QLatin1String("assistant"))
|
||||
beginAssistant();
|
||||
setBusy(true);
|
||||
}
|
||||
|
||||
void ChatModel::onChatStartFailed(const QString &error)
|
||||
{
|
||||
// Rimuove il placeholder assistente rimasto vuoto.
|
||||
if (m_msgs.size() > 0 && m_msgs.last().role == QLatin1String("assistant")
|
||||
&& m_msgs.last().content.isEmpty()) {
|
||||
beginRemoveRows(QModelIndex(), m_msgs.size() - 1, m_msgs.size() - 1);
|
||||
m_msgs.removeLast();
|
||||
endRemoveRows();
|
||||
emit rowCountChanged();
|
||||
}
|
||||
setBusy(false);
|
||||
emit notice(error);
|
||||
}
|
||||
|
||||
void ChatModel::onStreamDelta(const QString &text)
|
||||
{
|
||||
if (m_msgs.isEmpty() || m_msgs.last().role != QLatin1String("assistant"))
|
||||
beginAssistant();
|
||||
Msg &last = m_msgs.last();
|
||||
if (!last.streaming) {
|
||||
last.streaming = true;
|
||||
}
|
||||
last.content += text;
|
||||
const QModelIndex idx = index(m_msgs.size() - 1, 0);
|
||||
emit dataChanged(idx, idx, QVector<int>() << ContentRole << StreamingRole);
|
||||
emit streamTick();
|
||||
}
|
||||
|
||||
void ChatModel::onStreamDone(const QVariantMap &session)
|
||||
{
|
||||
m_finalizedThisRun = true;
|
||||
m_title = session.value(QStringLiteral("title")).toString();
|
||||
setFromMessages(session.value(QStringLiteral("messages")).toList());
|
||||
emit sessionInfoChanged();
|
||||
setBusy(false);
|
||||
emit assistantFinished();
|
||||
}
|
||||
|
||||
void ChatModel::onStreamEnded()
|
||||
{
|
||||
if (!m_msgs.isEmpty() && m_msgs.last().streaming) {
|
||||
Msg &last = m_msgs.last();
|
||||
last.streaming = false;
|
||||
// Placeholder rimasto vuoto (run interrotto prima del primo token).
|
||||
if (last.role == QLatin1String("assistant") && last.content.isEmpty()) {
|
||||
beginRemoveRows(QModelIndex(), m_msgs.size() - 1, m_msgs.size() - 1);
|
||||
m_msgs.removeLast();
|
||||
endRemoveRows();
|
||||
emit rowCountChanged();
|
||||
} else {
|
||||
const QModelIndex idx = index(m_msgs.size() - 1, 0);
|
||||
emit dataChanged(idx, idx, QVector<int>() << StreamingRole);
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
// Aggiorna la lista sessioni (titolo/ordinamento possono essere cambiati).
|
||||
m_api->refreshSessions();
|
||||
}
|
||||
|
||||
void ChatModel::onStreamError(const QString &error)
|
||||
{
|
||||
emit notice(error);
|
||||
// Lo stream puo' cadere per sospensione dell'app: risincronizza dallo stato
|
||||
// del server tra un attimo (se nel frattempo non e' gia' ripartito qualcosa).
|
||||
QTimer::singleShot(1200, this, [this]() {
|
||||
if (!m_api->streaming() && !m_sessionId.isEmpty())
|
||||
m_api->openSession(m_sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- modello ----
|
||||
|
||||
void ChatModel::setFromMessages(const QVariantList &messages)
|
||||
{
|
||||
beginResetModel();
|
||||
m_msgs.clear();
|
||||
for (int i = 0; i < messages.size(); ++i) {
|
||||
const QVariantMap m = messages.at(i).toMap();
|
||||
const QString role = m.value(QStringLiteral("role")).toString();
|
||||
if (role != QLatin1String("user") && role != QLatin1String("assistant"))
|
||||
continue;
|
||||
const QString content = m.value(QStringLiteral("content")).toString();
|
||||
// Messaggi assistente vuoti (solo chiamate a strumenti): non mostrati.
|
||||
if (role == QLatin1String("assistant") && content.trimmed().isEmpty())
|
||||
continue;
|
||||
Msg msg;
|
||||
msg.role = role;
|
||||
msg.content = content;
|
||||
msg.streaming = false;
|
||||
msg.ts = m.value(QStringLiteral("timestamp")).toDouble();
|
||||
m_msgs.append(msg);
|
||||
}
|
||||
endResetModel();
|
||||
emit rowCountChanged();
|
||||
}
|
||||
|
||||
void ChatModel::appendMessage(const QString &role, const QString &content, bool streaming)
|
||||
{
|
||||
beginInsertRows(QModelIndex(), m_msgs.size(), m_msgs.size());
|
||||
Msg msg;
|
||||
msg.role = role;
|
||||
msg.content = content;
|
||||
msg.streaming = streaming;
|
||||
msg.ts = 0;
|
||||
m_msgs.append(msg);
|
||||
endInsertRows();
|
||||
emit rowCountChanged();
|
||||
}
|
||||
|
||||
void ChatModel::beginAssistant()
|
||||
{
|
||||
appendMessage(QStringLiteral("assistant"), QString(), true);
|
||||
}
|
||||
|
||||
void ChatModel::setBusy(bool on)
|
||||
{
|
||||
if (on == m_busy)
|
||||
return;
|
||||
m_busy = on;
|
||||
emit busyChanged();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#ifndef CHATMODEL_H
|
||||
#define CHATMODEL_H
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include "apiclient.h"
|
||||
|
||||
// Modello della conversazione corrente + orchestrazione del flusso chat.
|
||||
//
|
||||
// Riceve gli eventi da ApiClient e mantiene la lista messaggi per la UI:
|
||||
// - apertura/nuova sessione -> reset dal JSON della sessione
|
||||
// - invio -> messaggio utente ottimistico + placeholder assistente
|
||||
// - streaming -> append dei delta "token"
|
||||
// - done -> risincronizzazione con i messaggi finali della sessione
|
||||
class ChatModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionInfoChanged)
|
||||
Q_PROPERTY(QString title READ title NOTIFY sessionInfoChanged)
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
Q_PROPERTY(bool canSend READ canSend NOTIFY busyChanged)
|
||||
Q_PROPERTY(int count READ count NOTIFY rowCountChanged)
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
RoleRole = Qt::UserRole + 1,
|
||||
ContentRole,
|
||||
StreamingRole,
|
||||
TimestampRole
|
||||
};
|
||||
|
||||
explicit ChatModel(ApiClient *api, QObject *parent = nullptr);
|
||||
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
|
||||
QString sessionId() const { return m_sessionId; }
|
||||
QString title() const { return m_title; }
|
||||
bool busy() const { return m_busy; }
|
||||
bool canSend() const { return !m_busy && !m_sessionId.isEmpty(); }
|
||||
int count() const { return m_msgs.size(); }
|
||||
|
||||
Q_INVOKABLE void openSession(const QString &id);
|
||||
Q_INVOKABLE void newSession();
|
||||
Q_INVOKABLE void send(const QString &text);
|
||||
Q_INVOKABLE void cancel();
|
||||
Q_INVOKABLE void resync();
|
||||
Q_INVOKABLE QString lastAssistantText() const;
|
||||
|
||||
signals:
|
||||
void sessionInfoChanged();
|
||||
void busyChanged();
|
||||
void rowCountChanged();
|
||||
void streamTick(); // ogni delta di streaming (per l'auto-scroll)
|
||||
void assistantFinished(); // risposta completata (per auto-read TTS)
|
||||
void notice(const QString &text); // avvisi non bloccanti per la UI
|
||||
|
||||
private:
|
||||
struct Msg {
|
||||
QString role;
|
||||
QString content;
|
||||
bool streaming;
|
||||
double ts;
|
||||
};
|
||||
|
||||
void setFromMessages(const QVariantList &messages);
|
||||
void appendMessage(const QString &role, const QString &content, bool streaming);
|
||||
void beginAssistant();
|
||||
void setBusy(bool on);
|
||||
|
||||
void onSessionOpened();
|
||||
void onChatStarted(const QString &streamId);
|
||||
void onChatStartFailed(const QString &error);
|
||||
void onStreamDelta(const QString &text);
|
||||
void onStreamDone(const QVariantMap &session);
|
||||
void onStreamEnded();
|
||||
void onStreamError(const QString &error);
|
||||
|
||||
ApiClient *m_api;
|
||||
QList<Msg> m_msgs;
|
||||
QString m_sessionId;
|
||||
QString m_title;
|
||||
bool m_busy;
|
||||
bool m_finalizedThisRun;
|
||||
};
|
||||
|
||||
#endif // CHATMODEL_H
|
||||
@@ -0,0 +1,72 @@
|
||||
#include "cookiejar.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QNetworkCookie>
|
||||
|
||||
CookieJar::CookieJar(QObject *parent)
|
||||
: QNetworkCookieJar(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void CookieJar::setFilePath(const QString &path)
|
||||
{
|
||||
m_path = path;
|
||||
}
|
||||
|
||||
QString CookieJar::filePath() const
|
||||
{
|
||||
return m_path;
|
||||
}
|
||||
|
||||
bool CookieJar::load()
|
||||
{
|
||||
if (m_path.isEmpty())
|
||||
return false;
|
||||
QFile f(m_path);
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
const QByteArray data = f.readAll();
|
||||
f.close();
|
||||
const QList<QNetworkCookie> cookies = QNetworkCookie::parseCookies(data);
|
||||
if (cookies.isEmpty())
|
||||
return false;
|
||||
setAllCookies(cookies);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CookieJar::save() const
|
||||
{
|
||||
if (m_path.isEmpty())
|
||||
return false;
|
||||
const QFileInfo info(m_path);
|
||||
QDir().mkpath(info.absolutePath());
|
||||
QFile f(m_path);
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
return false;
|
||||
// Permessi solo proprietario PRIMA di scrivere il contenuto.
|
||||
f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
const QList<QNetworkCookie> cookies = allCookies();
|
||||
for (int i = 0; i < cookies.size(); ++i) {
|
||||
f.write(cookies.at(i).toRawForm(QNetworkCookie::Full));
|
||||
f.write("\n");
|
||||
}
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
void CookieJar::clear()
|
||||
{
|
||||
setAllCookies(QList<QNetworkCookie>());
|
||||
if (!m_path.isEmpty())
|
||||
QFile::remove(m_path);
|
||||
}
|
||||
|
||||
bool CookieJar::setCookiesFromUrl(const QList<QNetworkCookie> &cookieList, const QUrl &url)
|
||||
{
|
||||
const bool changed = QNetworkCookieJar::setCookiesFromUrl(cookieList, url);
|
||||
if (changed)
|
||||
save();
|
||||
return changed;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef COOKIEJAR_H
|
||||
#define COOKIEJAR_H
|
||||
|
||||
#include <QNetworkCookie>
|
||||
#include <QNetworkCookieJar>
|
||||
#include <QString>
|
||||
|
||||
// Cookie jar persistente su file (0600).
|
||||
// Il cookie di sessione del WebUI di Hermes e' una credenziale: vive solo in
|
||||
// ~/.config/harbour/hermes/cookies.dat con permessi del solo proprietario.
|
||||
// Il salvataggio avviene automaticamente a ogni inserimento di cookie.
|
||||
class CookieJar : public QNetworkCookieJar
|
||||
{
|
||||
public:
|
||||
explicit CookieJar(QObject *parent = nullptr);
|
||||
|
||||
void setFilePath(const QString &path);
|
||||
QString filePath() const;
|
||||
|
||||
bool load();
|
||||
bool save() const;
|
||||
void clear();
|
||||
|
||||
protected:
|
||||
bool setCookiesFromUrl(const QList<QNetworkCookie> &cookieList, const QUrl &url) override;
|
||||
|
||||
private:
|
||||
QString m_path;
|
||||
};
|
||||
|
||||
#endif // COOKIEJAR_H
|
||||
@@ -0,0 +1,48 @@
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickView>
|
||||
#include <QDebug>
|
||||
#include <sailfishapp.h>
|
||||
|
||||
#include "apiclient.h"
|
||||
#include "chatmodel.h"
|
||||
#include "player.h"
|
||||
#include "recorder.h"
|
||||
#include "settings.h"
|
||||
|
||||
Q_DECL_EXPORT int main(int argc, char *argv[])
|
||||
{
|
||||
QGuiApplication *app = SailfishApp::application(argc, argv);
|
||||
|
||||
// Identita' allineata al profilo X-Sailjail del desktop file:
|
||||
// AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox).
|
||||
app->setOrganizationName(QStringLiteral("harbour"));
|
||||
app->setApplicationName(QStringLiteral("hermes"));
|
||||
app->setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
|
||||
qDebug() << "harbour-hermes v0.1.0 build" << __DATE__ << __TIME__;
|
||||
|
||||
Settings settings;
|
||||
ApiClient api(&settings);
|
||||
ChatModel chat(&api);
|
||||
Recorder recorder;
|
||||
Player player;
|
||||
|
||||
// Flusso voce: registrazione -> trascrizione; TTS pronto -> riproduzione.
|
||||
QObject::connect(&recorder, &Recorder::finished, &api, &ApiClient::transcribeFile);
|
||||
QObject::connect(&api, &ApiClient::ttsReady, &player, &Player::play);
|
||||
|
||||
QQuickView *view = SailfishApp::createView();
|
||||
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
|
||||
view->rootContext()->setContextProperty(QStringLiteral("api"), &api);
|
||||
view->rootContext()->setContextProperty(QStringLiteral("chat"), &chat);
|
||||
view->rootContext()->setContextProperty(QStringLiteral("recorder"), &recorder);
|
||||
view->rootContext()->setContextProperty(QStringLiteral("player"), &player);
|
||||
view->rootContext()->setContextProperty(QStringLiteral("appVersion"),
|
||||
QCoreApplication::applicationVersion());
|
||||
|
||||
view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-hermes.qml")));
|
||||
view->show();
|
||||
|
||||
return app->exec();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "player.h"
|
||||
|
||||
#include <QMediaContent>
|
||||
#include <QUrl>
|
||||
|
||||
Player::Player(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
connect(&m_player, &QMediaPlayer::stateChanged, this,
|
||||
[this](QMediaPlayer::State) { emit playingChanged(); });
|
||||
connect(&m_player, &QMediaPlayer::mediaStatusChanged, this,
|
||||
[this](QMediaPlayer::MediaStatus status) {
|
||||
if (status == QMediaPlayer::EndOfMedia
|
||||
|| status == QMediaPlayer::InvalidMedia) {
|
||||
emit playingChanged();
|
||||
emit finished();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool Player::playing() const
|
||||
{
|
||||
return m_player.state() == QMediaPlayer::PlayingState;
|
||||
}
|
||||
|
||||
void Player::play(const QString &path)
|
||||
{
|
||||
stop();
|
||||
m_player.setMedia(QMediaContent(QUrl::fromLocalFile(path)));
|
||||
m_player.play();
|
||||
emit playingChanged();
|
||||
}
|
||||
|
||||
void Player::stop()
|
||||
{
|
||||
if (m_player.state() != QMediaPlayer::StoppedState)
|
||||
m_player.stop();
|
||||
emit playingChanged();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef PLAYER_H
|
||||
#define PLAYER_H
|
||||
|
||||
#include <QMediaPlayer>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
// Riproduttore audio per le risposte TTS (QMediaPlayer, mp3 dal server).
|
||||
// L'output passa da PulseAudio; il volume segue il sistema.
|
||||
class Player : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool playing READ playing NOTIFY playingChanged)
|
||||
|
||||
public:
|
||||
explicit Player(QObject *parent = nullptr);
|
||||
|
||||
bool playing() const;
|
||||
|
||||
Q_INVOKABLE void play(const QString &path);
|
||||
Q_INVOKABLE void stop();
|
||||
|
||||
signals:
|
||||
void playingChanged();
|
||||
void finished();
|
||||
|
||||
private:
|
||||
QMediaPlayer m_player;
|
||||
};
|
||||
|
||||
#endif // PLAYER_H
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "recorder.h"
|
||||
|
||||
#include <QAudioEncoderSettings>
|
||||
#include <QAudioRecorder>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
#include <QVideoEncoderSettings>
|
||||
|
||||
namespace {
|
||||
|
||||
// Container preferito: OGG; ripiego WAV.
|
||||
QString chooseContainer(const QStringList &containers)
|
||||
{
|
||||
if (containers.contains(QStringLiteral("audio/ogg")))
|
||||
return QStringLiteral("audio/ogg");
|
||||
if (containers.contains(QStringLiteral("audio/x-wav")))
|
||||
return QStringLiteral("audio/x-wav");
|
||||
return containers.isEmpty() ? QString() : containers.first();
|
||||
}
|
||||
|
||||
// Codec preferito: Opus, poi Vorbis, poi PCM.
|
||||
QString chooseCodec(const QStringList &codecs)
|
||||
{
|
||||
if (codecs.contains(QStringLiteral("audio/opus")))
|
||||
return QStringLiteral("audio/opus");
|
||||
if (codecs.contains(QStringLiteral("audio/vorbis")))
|
||||
return QStringLiteral("audio/vorbis");
|
||||
if (codecs.contains(QStringLiteral("audio/pcm")))
|
||||
return QStringLiteral("audio/pcm");
|
||||
return codecs.isEmpty() ? QString() : codecs.first();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Recorder::Recorder(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_rec(new QAudioRecorder(this))
|
||||
, m_stopping(false)
|
||||
, m_fallbackTried(false)
|
||||
{
|
||||
connect(m_rec,
|
||||
static_cast<void (QAudioRecorder::*)(QMediaRecorder::State)>(&QAudioRecorder::stateChanged),
|
||||
this, [this](QMediaRecorder::State) { onStateChanged(); });
|
||||
connect(m_rec,
|
||||
static_cast<void (QAudioRecorder::*)(QMediaRecorder::Error)>(&QAudioRecorder::error),
|
||||
this, [this](QMediaRecorder::Error) { onError(); });
|
||||
|
||||
m_tick.setInterval(250);
|
||||
connect(&m_tick, &QTimer::timeout, this, [this]() { emit durationChanged(); });
|
||||
}
|
||||
|
||||
bool Recorder::recording() const
|
||||
{
|
||||
return m_rec->state() == QMediaRecorder::RecordingState;
|
||||
}
|
||||
|
||||
int Recorder::durationMs() const
|
||||
{
|
||||
return static_cast<int>(m_rec->duration());
|
||||
}
|
||||
|
||||
bool Recorder::start()
|
||||
{
|
||||
if (recording())
|
||||
return true;
|
||||
|
||||
const QString container = chooseContainer(m_rec->supportedContainers());
|
||||
const QString codec = chooseCodec(m_rec->supportedAudioCodecs());
|
||||
if (container.isEmpty() || codec.isEmpty()) {
|
||||
emit failed(QStringLiteral("No audio format available"));
|
||||
return false;
|
||||
}
|
||||
m_fallbackTried = false;
|
||||
return beginRecording(container, codec);
|
||||
}
|
||||
|
||||
void Recorder::stop()
|
||||
{
|
||||
if (!recording())
|
||||
return;
|
||||
m_stopping = true;
|
||||
m_rec->stop();
|
||||
}
|
||||
|
||||
bool Recorder::beginRecording(const QString &container, const QString &codec)
|
||||
{
|
||||
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
const QString dir = base + QStringLiteral("/rec");
|
||||
QDir().mkpath(dir);
|
||||
const QString ext = container == QLatin1String("audio/x-wav") ? QStringLiteral(".wav")
|
||||
: QStringLiteral(".ogg");
|
||||
m_path = dir + QStringLiteral("/rec-%1%2")
|
||||
.arg(QDateTime::currentMSecsSinceEpoch())
|
||||
.arg(ext);
|
||||
m_container = container;
|
||||
|
||||
QAudioEncoderSettings enc;
|
||||
enc.setCodec(codec);
|
||||
enc.setSampleRate(16000);
|
||||
enc.setChannelCount(1);
|
||||
enc.setQuality(QMultimedia::NormalQuality);
|
||||
m_rec->setEncodingSettings(enc, QVideoEncoderSettings(), container);
|
||||
m_rec->setOutputLocation(QUrl::fromLocalFile(m_path));
|
||||
|
||||
m_stopping = false;
|
||||
m_rec->record();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Recorder::onStateChanged()
|
||||
{
|
||||
const bool rec = recording();
|
||||
if (rec)
|
||||
m_tick.start();
|
||||
else
|
||||
m_tick.stop();
|
||||
emit recordingChanged();
|
||||
emit durationChanged();
|
||||
|
||||
if (!rec && m_stopping) {
|
||||
m_stopping = false;
|
||||
if (m_path.isEmpty())
|
||||
return;
|
||||
emit finished(m_path);
|
||||
}
|
||||
}
|
||||
|
||||
void Recorder::onError()
|
||||
{
|
||||
// Primo tentativo fallito (es. codec non disponibile nel target):
|
||||
// un solo ritentativo con WAV/PCM, poi si arrende.
|
||||
if (!m_fallbackTried && !m_path.isEmpty()) {
|
||||
m_fallbackTried = true;
|
||||
m_rec->stop();
|
||||
if (beginRecording(QStringLiteral("audio/x-wav"), QStringLiteral("audio/pcm")))
|
||||
return;
|
||||
}
|
||||
m_tick.stop();
|
||||
emit recordingChanged();
|
||||
emit failed(m_rec->errorString());
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#ifndef RECORDER_H
|
||||
#define RECORDER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
|
||||
class QAudioRecorder;
|
||||
|
||||
// Registratore vocale (QAudioRecorder) con fallback di formato:
|
||||
// container/codec preferiti OGG/Opus, poi OGG/Vorbis, poi WAV/PCM.
|
||||
// Se il primo tentativo fallisce a runtime (codec assente nel target),
|
||||
// riprova una volta con WAV/PCM.
|
||||
// I file finiscono in <AppData>/rec/ (zona dati della sandbox SailJail).
|
||||
// Il permesso Microphone e' dichiarato nel profilo X-Sailjail del desktop file.
|
||||
class Recorder : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool recording READ recording NOTIFY recordingChanged)
|
||||
Q_PROPERTY(int durationMs READ durationMs NOTIFY durationChanged)
|
||||
|
||||
public:
|
||||
explicit Recorder(QObject *parent = nullptr);
|
||||
|
||||
bool recording() const;
|
||||
int durationMs() const;
|
||||
|
||||
Q_INVOKABLE bool start();
|
||||
Q_INVOKABLE void stop();
|
||||
|
||||
signals:
|
||||
void recordingChanged();
|
||||
void durationChanged();
|
||||
void finished(const QString &path); // file pronto per la trascrizione
|
||||
void failed(const QString &error);
|
||||
|
||||
private:
|
||||
void onStateChanged();
|
||||
void onError();
|
||||
bool beginRecording(const QString &container, const QString &codec);
|
||||
|
||||
QAudioRecorder *m_rec;
|
||||
QTimer m_tick;
|
||||
QString m_path;
|
||||
QString m_container;
|
||||
bool m_stopping;
|
||||
bool m_fallbackTried;
|
||||
};
|
||||
|
||||
#endif // RECORDER_H
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "settings.h"
|
||||
|
||||
// Default: l'istanza pubblica del WebUI usa questo host (HTTPS, cert valido).
|
||||
// L'utente puo' cambiarlo dalle impostazioni (LAN, VPN, altro dominio).
|
||||
static const char *kDefaultBaseUrl = "https://hermes.hackatoniclife.com";
|
||||
static const char *kDefaultTtsVoice = "it-IT-ElsaNeural";
|
||||
static const char *kDefaultTtsEngine = "edge";
|
||||
|
||||
Settings::Settings(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
QString Settings::baseUrl() const
|
||||
{
|
||||
QString v = m_settings.value(QStringLiteral("baseUrl"), QString::fromLatin1(kDefaultBaseUrl)).toString();
|
||||
// Normalizza: niente slash finale (componiamo gli URL con path assoluti).
|
||||
while (v.endsWith(QLatin1Char('/')))
|
||||
v.chop(1);
|
||||
return v;
|
||||
}
|
||||
|
||||
void Settings::setBaseUrl(const QString &url)
|
||||
{
|
||||
QString v = url.trimmed();
|
||||
while (v.endsWith(QLatin1Char('/')))
|
||||
v.chop(1);
|
||||
if (v == baseUrl())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("baseUrl"), v);
|
||||
emit baseUrlChanged();
|
||||
}
|
||||
|
||||
QString Settings::ttsVoice() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("ttsVoice"), QString::fromLatin1(kDefaultTtsVoice)).toString();
|
||||
}
|
||||
|
||||
void Settings::setTtsVoice(const QString &voice)
|
||||
{
|
||||
if (voice == ttsVoice())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("ttsVoice"), voice);
|
||||
emit ttsVoiceChanged();
|
||||
}
|
||||
|
||||
QString Settings::ttsEngine() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("ttsEngine"), QString::fromLatin1(kDefaultTtsEngine)).toString();
|
||||
}
|
||||
|
||||
void Settings::setTtsEngine(const QString &engine)
|
||||
{
|
||||
if (engine == ttsEngine())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("ttsEngine"), engine);
|
||||
emit ttsEngineChanged();
|
||||
}
|
||||
|
||||
bool Settings::ttsAutoRead() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("ttsAutoRead"), false).toBool();
|
||||
}
|
||||
|
||||
void Settings::setTtsAutoRead(bool on)
|
||||
{
|
||||
if (on == ttsAutoRead())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("ttsAutoRead"), on);
|
||||
emit ttsAutoReadChanged();
|
||||
}
|
||||
|
||||
QString Settings::defaultWorkspace() const
|
||||
{
|
||||
return m_settings.value(QStringLiteral("defaultWorkspace"), QString()).toString();
|
||||
}
|
||||
|
||||
void Settings::setDefaultWorkspace(const QString &ws)
|
||||
{
|
||||
if (ws == defaultWorkspace())
|
||||
return;
|
||||
m_settings.setValue(QStringLiteral("defaultWorkspace"), ws);
|
||||
emit defaultWorkspaceChanged();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#ifndef SETTINGS_H
|
||||
#define SETTINGS_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QSettings>
|
||||
#include <QString>
|
||||
|
||||
// Impostazioni persistenti dell'app.
|
||||
// QSettings con OrganizationName "harbour" / ApplicationName "hermes" (impostati
|
||||
// in main.cpp, allineati al profilo X-Sailjail del desktop file): file in
|
||||
// ~/.config/harbour/hermes.conf (zona persistente della sandbox).
|
||||
//
|
||||
// La password NON viene mai salvata: l'autenticazione si basa sul cookie di
|
||||
// sessione persistito in modo protetto (vedi CookieJar).
|
||||
class Settings : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
|
||||
Q_PROPERTY(QString ttsVoice READ ttsVoice WRITE setTtsVoice NOTIFY ttsVoiceChanged)
|
||||
Q_PROPERTY(QString ttsEngine READ ttsEngine WRITE setTtsEngine NOTIFY ttsEngineChanged)
|
||||
Q_PROPERTY(bool ttsAutoRead READ ttsAutoRead WRITE setTtsAutoRead NOTIFY ttsAutoReadChanged)
|
||||
Q_PROPERTY(QString defaultWorkspace READ defaultWorkspace WRITE setDefaultWorkspace
|
||||
NOTIFY defaultWorkspaceChanged)
|
||||
|
||||
public:
|
||||
explicit Settings(QObject *parent = nullptr);
|
||||
|
||||
QString baseUrl() const;
|
||||
void setBaseUrl(const QString &url);
|
||||
|
||||
QString ttsVoice() const;
|
||||
void setTtsVoice(const QString &voice);
|
||||
|
||||
QString ttsEngine() const;
|
||||
void setTtsEngine(const QString &engine);
|
||||
|
||||
bool ttsAutoRead() const;
|
||||
void setTtsAutoRead(bool on);
|
||||
|
||||
QString defaultWorkspace() const;
|
||||
void setDefaultWorkspace(const QString &ws);
|
||||
|
||||
signals:
|
||||
void baseUrlChanged();
|
||||
void ttsVoiceChanged();
|
||||
void ttsEngineChanged();
|
||||
void ttsAutoReadChanged();
|
||||
void defaultWorkspaceChanged();
|
||||
|
||||
private:
|
||||
QSettings m_settings;
|
||||
};
|
||||
|
||||
#endif // SETTINGS_H
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "sseparser.h"
|
||||
|
||||
SseParser::SseParser()
|
||||
{
|
||||
}
|
||||
|
||||
QList<SseParser::Event> SseParser::feed(const QByteArray &chunk)
|
||||
{
|
||||
m_buffer += chunk;
|
||||
// Normalizza le terminazioni di riga (il server usa \n, ma per sicurezza).
|
||||
if (m_buffer.contains('\r'))
|
||||
m_buffer.replace("\r\n", "\n");
|
||||
|
||||
QList<Event> events;
|
||||
int idx = -1;
|
||||
while ((idx = m_buffer.indexOf("\n\n")) >= 0) {
|
||||
const QByteArray block = m_buffer.left(idx);
|
||||
m_buffer.remove(0, idx + 2);
|
||||
|
||||
Event ev;
|
||||
bool hasData = false;
|
||||
const QList<QByteArray> lines = block.split('\n');
|
||||
for (int i = 0; i < lines.size(); ++i) {
|
||||
const QByteArray &line = lines.at(i);
|
||||
if (line.isEmpty() || line.startsWith(':'))
|
||||
continue; // riga vuota o commento/keepalive
|
||||
if (line.startsWith("event:")) {
|
||||
ev.name = QString::fromUtf8(line.mid(6)).trimmed();
|
||||
} else if (line.startsWith("data:")) {
|
||||
QString d = QString::fromUtf8(line.mid(5));
|
||||
if (d.startsWith(QLatin1Char(' ')))
|
||||
d.remove(0, 1);
|
||||
if (hasData)
|
||||
ev.data += QLatin1Char('\n');
|
||||
ev.data += d;
|
||||
hasData = true;
|
||||
} else if (line.startsWith("id:")) {
|
||||
ev.id = QString::fromUtf8(line.mid(3)).trimmed();
|
||||
}
|
||||
}
|
||||
// Blocco di soli commenti/keepalive: niente da consegnare.
|
||||
if (!hasData)
|
||||
continue;
|
||||
if (ev.name.isEmpty())
|
||||
ev.name = QStringLiteral("message");
|
||||
events.append(ev);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
void SseParser::reset()
|
||||
{
|
||||
m_buffer.clear();
|
||||
}
|
||||
|
||||
QByteArray SseParser::pending() const
|
||||
{
|
||||
return m_buffer;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#ifndef SSEPARSER_H
|
||||
#define SSEPARSER_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
// Parser incrementale di Server-Sent Events (text/event-stream).
|
||||
//
|
||||
// Il WebUI di Hermes emette eventi nella forma:
|
||||
// id: <stream_id>:<seq>
|
||||
// event: token
|
||||
// data: {"text": "..."}
|
||||
// <riga vuota>
|
||||
// piu' commenti di keepalive (": heartbeat") da ignorare.
|
||||
//
|
||||
// Logica pura, senza dipendenze da rete: alimentato con i chunk grezzi,
|
||||
// restituisce gli eventi completi. Testabile in locale (tests/sse_test.cpp).
|
||||
class SseParser
|
||||
{
|
||||
public:
|
||||
struct Event {
|
||||
QString name; // campo "event:" (vuoto => "message" SSE standard)
|
||||
QString data; // campi "data:" concatenati con "\n"
|
||||
QString id; // campo "id:" (cursore per replay)
|
||||
};
|
||||
|
||||
SseParser();
|
||||
|
||||
// Aggiunge un chunk e restituisce tutti gli eventi completati.
|
||||
QList<Event> feed(const QByteArray &chunk);
|
||||
|
||||
void reset();
|
||||
QByteArray pending() const;
|
||||
|
||||
private:
|
||||
QByteArray m_buffer;
|
||||
};
|
||||
|
||||
#endif // SSEPARSER_H
|
||||
Reference in New Issue
Block a user