fix(sessions): lista vuota sul device — modello C++ con ruoli (v0.1.2)

Causa: su Qt 5.6 (kit Sailfish 5.1) una QVariantList di QVariantMap
usata come model non fa risolvere i campi nei delegate QML
(model.title/message_count/updated_at = undefined -> righe vuote), pur
con payload corretto dal server (verificato con cattura reale del 12/09).

- Nuovo src/sessionsmodel.{h,cpp}: QAbstractListModel con ruoli
  session_id/title/message_count/updated_at; api.sessions ora e' il
  modello, api.sessionsCount per lo stato "nessuna sessione"
- /api/sessions con sidebar_source=webui&exclude_hidden=1 (come la
  sidebar del browser: solo sessioni WebUI, niente CLI/cron/nascoste)
- SessionsPage: describe(message_count, updated_at) con parametri espliciti
- PROTOCOL.md: payload sessions verificato + nota del pitfall Qt 5.6
This commit is contained in:
2026-09-12 08:47:28 +02:00
parent 98139be96a
commit 479edb0f99
11 changed files with 157 additions and 30 deletions
+1 -1
View File
@@ -29,6 +29,6 @@ speaks (validated live against a real instance).
## Status ## Status
Version 0.1.1 — protocol and C++ core validated against a real `hermes-webui` Version 0.1.2 — protocol and C++ core validated against a real `hermes-webui`
(login, sessions, streaming chat, TTS, STT); on-device testing (audio paths, (login, sessions, streaming chat, TTS, STT); on-device testing (audio paths,
Sailjail prompts, keyboard handling) still pending. Sailjail prompts, keyboard handling) still pending.
+3 -3
View File
@@ -76,12 +76,12 @@ richiede il probe WebUI su :8899). Esito 12/09/2026: unit 9/9, live completo OK.
```bash ```bash
cd /home/kaneda/workspace cd /home/kaneda/workspace
tar czf harbour-hermes-0.1.1.tar.gz \ tar czf harbour-hermes-0.1.2.tar.gz \
--transform 's,^harbour-hermes,harbour-hermes-0.1.1,' \ --transform 's,^harbour-hermes,harbour-hermes-0.1.2,' \
--exclude='.git' harbour-hermes --exclude='.git' harbour-hermes
``` ```
Lo spec si aspetta la directory `harbour-hermes-0.1.1/` (pattern degli altri Lo spec si aspetta la directory `harbour-hermes-0.1.2/` (pattern degli altri
progetti: cercato in modo robusto anche per i sorgenti live di sfdk). progetti: cercato in modo robusto anche per i sorgenti live di sfdk).
## 5. Dipendenze lato server (WebUI) ## 5. Dipendenze lato server (WebUI)
+24 -13
View File
@@ -30,32 +30,43 @@ server ha una password impostata.
## 2. Sessioni ## 2. Sessioni
`GET /api/sessions` → lista per la sidebar: `GET /api/sessions?sidebar_source=webui&exclude_hidden=1` → lista per la
sidebar (stessi parametri del frontend web: solo sessioni WebUI visibili;
senza parametri arrivano anche le CLI/cron e le nascoste — lista più lunga).
**Cattura reale del 12/09/2026** (chiavi verificate):
```json ```json
{ {
"sessions": [ "sessions": [
{ {
"session_id": "171ff18f9313", "session_id": "20260907_123845_c7e52d",
"title": "Webui Session", "title": "Diagnosi guasto YunoHost",
"workspace": "/home/kaneda/.hermes/profiles/carlo/home/workspace", "workspace": "/home/kaneda/.hermes/profiles/carlo/home/workspace",
"model": "deepseek-v4-flash", "model": "deepseek-v4-flash",
"model_provider": "deepseek", "message_count": 163,
"message_count": 2, "created_at": 1788707925.6,
"created_at": 1785701975.8797414, "updated_at": 1788778530.726691,
"updated_at": 1785701985.0872252, "pinned": false, "archived": false, "project_id": null,
"pinned": false, "archived": false,
"profile": "default", "profile": "default",
"source_tag": "webui", "session_source": "webui", "source_tag": "webui", "raw_source": "webui",
"is_cli_session": false, "is_streaming": false, "session_source": "webui", "source_label": "WebUI",
"active_stream_id": null "parent_session_id": null
} }
], ],
"active_profile": "default", "sidebar_reference_sessions": [ "..." ],
"server_time": 1789188804.1041577 "cli_count": 4, "archived_count": 0,
"archived_webui_count": 0, "archived_cli_count": 0,
"include_archived": false, "all_profiles": false,
"active_profile": "default"
} }
``` ```
⚠ Qt 5.6 (kit Sailfish 5.1): i campi di questi oggetti NON si risolvono nei
delegate QML quando il modello è una QVariantList (`model.campo` = undefined
→ righe vuote pur con dati corretti dal server). Il client usa un
QAbstractListModel C++ (`SessionsModel`) con ruoli `session_id`, `title`,
`message_count`, `updated_at`.
`GET /api/session?session_id=ID` → `{"session": { ... }}` con in più: `GET /api/session?session_id=ID` → `{"session": { ... }}` con in più:
- `messages`: array di `{"role": "user"|"assistant", "content": "...", - `messages`: array di `{"role": "user"|"assistant", "content": "...",
+2
View File
@@ -14,6 +14,7 @@ SOURCES += \
src/sseparser.cpp \ src/sseparser.cpp \
src/apiclient.cpp \ src/apiclient.cpp \
src/chatmodel.cpp \ src/chatmodel.cpp \
src/sessionsmodel.cpp \
src/recorder.cpp \ src/recorder.cpp \
src/player.cpp src/player.cpp
@@ -23,6 +24,7 @@ HEADERS += \
src/sseparser.h \ src/sseparser.h \
src/apiclient.h \ src/apiclient.h \
src/chatmodel.h \ src/chatmodel.h \
src/sessionsmodel.h \
src/recorder.h \ src/recorder.h \
src/player.h src/player.h
+6 -6
View File
@@ -58,7 +58,7 @@ Page {
width: parent.width width: parent.width
font.pixelSize: Theme.fontSizeExtraSmall font.pixelSize: Theme.fontSizeExtraSmall
color: Theme.secondaryColor color: Theme.secondaryColor
text: page.describe(model) text: page.describe(model.message_count, model.updated_at)
} }
} }
@@ -66,7 +66,7 @@ Page {
} }
ViewPlaceholder { ViewPlaceholder {
enabled: api.sessions.length === 0 && !api.busy enabled: api.sessionsCount === 0 && !api.busy
text: qsTr("No sessions") text: qsTr("No sessions")
hintText: qsTr("Pull down to create a new session") hintText: qsTr("Pull down to create a new session")
} }
@@ -119,9 +119,9 @@ Page {
chat.newSession() chat.newSession()
} }
function describe(m) { function describe(n, u) {
var n = m.message_count ? m.message_count : 0 var c = n ? n : 0
var d = new Date((m.updated_at ? m.updated_at : 0) * 1000) var d = new Date((u ? u : 0) * 1000)
return n + qsTr(" messages") + " · " + Qt.formatDateTime(d, "dd/MM HH:mm") return c + qsTr(" messages") + " · " + Qt.formatDateTime(d, "dd/MM HH:mm")
} }
} }
+7 -1
View File
@@ -1,6 +1,6 @@
Name: harbour-hermes Name: harbour-hermes
Summary: Client for the Hermes Web UI with voice Summary: Client for the Hermes Web UI with voice
Version: 0.1.1 Version: 0.1.2
Release: 1 Release: 1
Group: Qt/Qt Group: Qt/Qt
License: MIT License: MIT
@@ -24,6 +24,12 @@ Sessions list, chat with live streaming replies, voice dictation
(server-side TTS), plus a hands-free conversation mode. (server-side TTS), plus a hands-free conversation mode.
%changelog %changelog
* Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.2-1
- Fix lista sessioni vuota sul device: le QVariantList di mappe non
espongono i campi nei delegate su Qt 5.6 -> nuovo SessionsModel
(QAbstractListModel con ruoli: session_id/title/message_count/updated_at)
- /api/sessions con i parametri della sidebar web (sidebar_source=webui,
exclude_hidden=1): solo sessioni WebUI, niente CLI/cron
* Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.1-1 * Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.1-1
- Fix pagine non caricabili sul device: TextField.onAccepted inesistente - Fix pagine non caricabili sul device: TextField.onAccepted inesistente
-> EnterKey; Button.highlighted rimosso; ComboBox senza Repeater; -> EnterKey; Button.highlighted rimosso; ComboBox senza Repeater;
+16 -3
View File
@@ -15,6 +15,7 @@ ApiClient::ApiClient(Settings *settings, QObject *parent)
: QObject(parent) : QObject(parent)
, m_settings(settings) , m_settings(settings)
, m_jar(new CookieJar(this)) , m_jar(new CookieJar(this))
, m_sessionsModel(new SessionsModel(this))
, m_loggedIn(false) , m_loggedIn(false)
, m_busy(false) , m_busy(false)
, m_streaming(false) , m_streaming(false)
@@ -170,7 +171,7 @@ void ApiClient::logout()
}); });
m_jar->clear(); m_jar->clear();
setLoggedIn(false); setLoggedIn(false);
m_sessions.clear(); m_sessionsModel->setSessions(QVariantList());
emit sessionsChanged(); emit sessionsChanged();
m_session.clear(); m_session.clear();
emit sessionChanged(); emit sessionChanged();
@@ -181,7 +182,14 @@ void ApiClient::logout()
void ApiClient::refreshSessions() void ApiClient::refreshSessions()
{ {
setBusy(true); setBusy(true);
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/sessions"))); // Stessi parametri della sidebar del WebUI: solo sessioni WebUI visibili
// (senza parametri arriverebbero anche le CLI/cron e le nascoste).
QUrl url = apiUrl(QStringLiteral("/api/sessions"));
QUrlQuery q;
q.addQueryItem(QStringLiteral("sidebar_source"), QStringLiteral("webui"));
q.addQueryItem(QStringLiteral("exclude_hidden"), QStringLiteral("1"));
url.setQuery(q);
QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
setBusy(false); setBusy(false);
@@ -192,11 +200,16 @@ void ApiClient::refreshSessions()
} }
const QJsonObject obj = QJsonDocument::fromJson(body).object(); const QJsonObject obj = QJsonDocument::fromJson(body).object();
const QJsonArray arr = obj.value(QStringLiteral("sessions")).toArray(); const QJsonArray arr = obj.value(QStringLiteral("sessions")).toArray();
m_sessions = arr.toVariantList(); m_sessionsModel->setSessions(arr.toVariantList());
emit sessionsChanged(); emit sessionsChanged();
}); });
} }
int ApiClient::sessionsCount() const
{
return m_sessionsModel->rowCount();
}
void ApiClient::openSession(const QString &id) void ApiClient::openSession(const QString &id)
{ {
setLastError(QString()); setLastError(QString());
+6 -3
View File
@@ -9,6 +9,7 @@
#include "cookiejar.h" #include "cookiejar.h"
#include "settings.h" #include "settings.h"
#include "sessionsmodel.h"
#include "sseparser.h" #include "sseparser.h"
class QHttpMultiPart; class QHttpMultiPart;
@@ -36,7 +37,8 @@ class ApiClient : public QObject
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY(bool transcribing READ transcribing NOTIFY transcribingChanged) Q_PROPERTY(bool transcribing READ transcribing NOTIFY transcribingChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(QVariantList sessions READ sessions NOTIFY sessionsChanged) Q_PROPERTY(SessionsModel *sessions READ sessions NOTIFY sessionsChanged)
Q_PROPERTY(int sessionsCount READ sessionsCount NOTIFY sessionsChanged)
Q_PROPERTY(QVariantMap session READ session NOTIFY sessionChanged) Q_PROPERTY(QVariantMap session READ session NOTIFY sessionChanged)
public: public:
@@ -47,7 +49,8 @@ public:
bool streaming() const { return m_streaming; } bool streaming() const { return m_streaming; }
bool transcribing() const { return m_transcribing; } bool transcribing() const { return m_transcribing; }
QString lastError() const { return m_lastError; } QString lastError() const { return m_lastError; }
QVariantList sessions() const { return m_sessions; } SessionsModel *sessions() const { return m_sessionsModel; }
int sessionsCount() const;
QVariantMap session() const { return m_session; } QVariantMap session() const { return m_session; }
// Avvio: verifica lo stato auth (cookie persistito o auth disabilitata). // Avvio: verifica lo stato auth (cookie persistito o auth disabilitata).
@@ -119,7 +122,7 @@ private:
bool m_transcribing; bool m_transcribing;
QString m_lastError; QString m_lastError;
QVariantList m_sessions; SessionsModel *m_sessionsModel;
QVariantMap m_session; QVariantMap m_session;
QString m_streamId; QString m_streamId;
+52
View File
@@ -0,0 +1,52 @@
#include "sessionsmodel.h"
#include <QVariantMap>
SessionsModel::SessionsModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int SessionsModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return m_rows.size();
}
QVariant SessionsModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size())
return QVariant();
const QVariantMap row = m_rows.at(index.row()).toMap();
switch (role) {
case SessionIdRole:
return row.value(QStringLiteral("session_id"));
case TitleRole:
return row.value(QStringLiteral("title"));
case MessageCountRole:
return row.value(QStringLiteral("message_count"));
case UpdatedAtRole:
return row.value(QStringLiteral("updated_at"));
default:
return QVariant();
}
}
QHash<int, QByteArray> SessionsModel::roleNames() const
{
QHash<int, QByteArray> names;
names[SessionIdRole] = "session_id";
names[TitleRole] = "title";
names[MessageCountRole] = "message_count";
names[UpdatedAtRole] = "updated_at";
return names;
}
void SessionsModel::setSessions(const QVariantList &sessions)
{
beginResetModel();
m_rows = sessions;
endResetModel();
}
+38
View File
@@ -0,0 +1,38 @@
#ifndef SESSIONSMODEL_H
#define SESSIONSMODEL_H
#include <QAbstractListModel>
#include <QVariantList>
// Modello delle sessioni per la lista QML.
//
// Perché non una QVariantList di mappe: sul kit Sailfish 5.1 (Qt 5.6) i campi
// di una QVariantMap dentro una lista non si risolvono nei delegate
// (model.campo = undefined -> righe vuote, pur con dati corretti dal server).
// I ruoli di un QAbstractListModel sono il meccanismo supportato da sempre
// (stesso principio dei ListModel usati con successo negli altri progetti).
class SessionsModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
SessionIdRole = Qt::UserRole + 1,
TitleRole,
MessageCountRole,
UpdatedAtRole,
};
explicit SessionsModel(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;
// Sostituisce i dati (reset del modello: la lista QML si aggiorna).
void setSessions(const QVariantList &sessions);
private:
QVariantList m_rows;
};
#endif // SESSIONSMODEL_H
+2
View File
@@ -19,6 +19,7 @@ SOURCES += \
$$SRC/settings.cpp \ $$SRC/settings.cpp \
$$SRC/apiclient.cpp \ $$SRC/apiclient.cpp \
$$SRC/chatmodel.cpp \ $$SRC/chatmodel.cpp \
$$SRC/sessionsmodel.cpp \
$$SRC/recorder.cpp \ $$SRC/recorder.cpp \
$$SRC/player.cpp \ $$SRC/player.cpp \
core_test.cpp core_test.cpp
@@ -29,5 +30,6 @@ HEADERS += \
$$SRC/settings.h \ $$SRC/settings.h \
$$SRC/apiclient.h \ $$SRC/apiclient.h \
$$SRC/chatmodel.h \ $$SRC/chatmodel.h \
$$SRC/sessionsmodel.h \
$$SRC/recorder.h \ $$SRC/recorder.h \
$$SRC/player.h $$SRC/player.h