#include "chatstore.hpp" #include "chat.hpp" #include "message.hpp" #include #include #include #include #include #include #include #include #include #include namespace ZShell { ChatStore::ChatStore(QObject* parent) : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { openDb(); load(); } ChatStore::~ChatStore() { if (m_connectionName.isEmpty()) return; QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); db.close(); QSqlDatabase::removeDatabase(m_connectionName); } QSqlDatabase ChatStore::db() const { return QSqlDatabase::database(m_connectionName); } void ChatStore::openDb() { const QString path = QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) + QStringLiteral("/zshell/chats.sqlite"); QDir().mkpath(QFileInfo(path).absolutePath()); QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); db.setDatabaseName(path); if (!db.open()) { qWarning() << "ChatStore: failed to open database" << path << ":" << db.lastError().text(); return; } { QSqlQuery pragma(db); pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); } { QSqlQuery query(db); query.exec( QStringLiteral( "CREATE TABLE IF NOT EXISTS sessions (\n" " id TEXT PRIMARY KEY,\n" " title TEXT NOT NULL DEFAULT '',\n" " icon TEXT NOT NULL DEFAULT '',\n" " created_at INTEGER NOT NULL,\n" " updated_at INTEGER NOT NULL,\n" " pinned INTEGER NOT NULL DEFAULT 0\n" ")")); } { QSqlQuery info(db); bool hasIcon = false; if (info.exec(QStringLiteral("PRAGMA table_info(sessions)"))) while (info.next()) if (info.value(1).toString() == QLatin1String("icon")) { hasIcon = true; break; } if (!hasIcon) { QSqlQuery alter(db); if (!alter.exec(QStringLiteral( "ALTER TABLE sessions ADD COLUMN icon TEXT NOT NULL " "DEFAULT ''"))) qWarning() << "ChatStore: failed to add icon column:" << alter.lastError().text(); } } { QSqlQuery query(db); query.exec( QStringLiteral( "CREATE TABLE IF NOT EXISTS messages (\n" " session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE " "CASCADE,\n" " role TEXT NOT NULL,\n" " content TEXT,\n" " reasoning TEXT,\n" " timestamp INTEGER NOT NULL,\n" " reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" " content_elapsed_ms INTEGER NOT NULL DEFAULT 0\n" ")")); } } int ChatStore::count() const { return static_cast(m_sessions.size()); } QVariantList ChatStore::values() const { QVariantList vals; vals.reserve(m_sessions.size()); for (const auto* session : m_sessions) vals.append(QVariant::fromValue(session)); return vals; } ChatSession* ChatStore::at(int index) const { if (index < 0 || index >= m_sessions.size()) return nullptr; return m_sessions.at(index); } ChatSession* ChatStore::insert(int index) { const qint64 now = QDateTime::currentMSecsSinceEpoch(); const QString id = QString::number(now); { QSqlQuery query(db()); query.prepare( "INSERT INTO sessions (id, title, created_at, updated_at) " "VALUES (:id, '', :created_at, :updated_at)"); query.bindValue(":id", id); query.bindValue(":created_at", now); query.bindValue(":updated_at", now); if (!query.exec()) qWarning() << "ChatStore: failed to insert session" << id << ":" << query.lastError().text(); } auto* session = new ChatSession(id, this); session->setMeta(QString(), now, now, 0); const int pos = index >= 0 && index <= m_sessions.size() ? index : 0; m_sessions.insert(pos, session); Q_EMIT countChanged(); Q_EMIT valuesChanged(); return session; } void ChatStore::remove(int index) { removeSession(at(index)); } void ChatStore::remove(ChatSession* chat) { removeSession(chat); } void ChatStore::removeSession(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; if (auto* chat = qobject_cast(parent())) if (chat->m_active == session) { chat->stop(); chat->endStream(); } const QList before = m_sessions; { QSqlQuery query(db()); query.prepare("DELETE FROM sessions WHERE id = :id"); query.bindValue(":id", session->id()); query.exec(); } m_sessions.removeOne(session); session->deleteLater(); notify(before); } void ChatStore::move(int from, int to) { if (from < 0 || from >= m_sessions.size() || to < 0 || to >= m_sessions.size() || from == to) return; m_sessions.move(from, to); Q_EMIT valuesChanged(); } void ChatStore::clear() { const QList sessions = m_sessions; for (ChatSession* session : sessions) removeSession(session); } ChatSession* ChatStore::sessionById(const QString& id) { for (auto* session : m_sessions) if (session->id() == id) return session; return nullptr; } void ChatStore::persist(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); session->ensureLoaded(); if (!saveSession(session)) return; sortAndNotify(); } void ChatStore::saveMeta(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; QSqlQuery query(db()); query.prepare("UPDATE sessions SET title = :title, icon = :icon " "WHERE id = :id"); query.bindValue(":title", session->title()); query.bindValue(":icon", session->icon()); query.bindValue(":id", session->id()); if (!query.exec()) qWarning() << "ChatStore: failed to save meta for" << session->id() << ":" << query.lastError().text(); } bool ChatStore::saveSession(ChatSession* session) { const QString id = session->id(); QSqlDatabase handle = db(); if (!handle.transaction()) { qWarning() << "ChatStore: failed to begin transaction:" << handle.lastError().text(); return false; } bool ok = true; { QSqlQuery query(handle); query.prepare( "UPDATE sessions SET title = :title, updated_at = :updated_at " "WHERE id = :id"); query.bindValue(":title", session->title()); query.bindValue(":updated_at", session->updatedAtMs()); query.bindValue(":id", session->id()); ok = query.exec(); } if (ok) { QSqlQuery query(handle); query.prepare("DELETE FROM messages WHERE session_id = :id"); query.bindValue(":id", session->id()); ok = query.exec(); } if (ok) { QSqlQuery insert(handle); ok = insert.prepare( "INSERT INTO messages (session_id, role, content, reasoning, " "timestamp, reasoning_elapsed_ms, content_elapsed_ms) " "VALUES (:id, :role, :content, :reasoning, :timestamp, " ":reasoning_elapsed_ms, :content_elapsed_ms)"); for (const auto* message : session->messages()) { insert.bindValue(":id", session->id()); insert.bindValue( ":role", message->role() == ChatMessage::Role::User ? QStringLiteral("user") : QStringLiteral("assistant")); insert.bindValue(":content", message->content()); insert.bindValue(":reasoning", message->reasoning()); insert.bindValue(":timestamp", message->timestamp()); insert.bindValue(":reasoning_elapsed_ms", message->reasoningElapsedMs()); insert.bindValue(":content_elapsed_ms", message->contentElapsedMs()); if (!insert.exec()) { ok = false; qWarning() << "ChatStore: saveSession" << id << "insert failed:" << insert.lastError().text(); break; } } } if (!ok || !handle.commit()) { qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; handle.rollback(); ok = false; qWarning() << "ChatStore: failed to save session" << session->id() << ":" << handle.lastError().text(); } return ok; } void ChatStore::loadMessagesInto(ChatSession* session) { QSqlQuery query(db()); query.prepare( "SELECT role, content, reasoning, timestamp, reasoning_elapsed_ms, " "content_elapsed_ms FROM messages WHERE session_id = :id ORDER BY rowid"); query.bindValue(":id", session->id()); if (!query.exec()) { qWarning() << "ChatStore: failed to load messages for" << session->id() << ":" << query.lastError().text(); return; } QList messages; while (query.next()) { auto* message = new ChatMessage( query.value(0).toString() == QLatin1String("user") ? ChatMessage::Role::User : ChatMessage::Role::Assistant, query.value(1).toString(), query.value(3).toLongLong(), session); message->setReasoning(query.value(2).toString()); message->setElapsedMs( query.value(4).toLongLong(), query.value(5).toLongLong()); messages.append(message); } session->adoptMessages(messages); } void ChatStore::load() { QSqlQuery query(db()); query.exec( "SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, " "(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt " "FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC"); while (query.next()) { auto* session = new ChatSession(query.value(0).toString(), this); session->setMeta( query.value(1).toString(), query.value(3).toLongLong(), query.value(4).toLongLong(), query.value(6).toInt()); session->setIcon(query.value(2).toString()); session->setPinned(query.value(5).toInt() != 0); m_sessions.append(session); } sortAndNotify(); } void ChatStore::sortAndNotify() { const QList before = m_sessions; std::stable_sort( m_sessions.begin(), m_sessions.end(), [](const ChatSession* a, const ChatSession* b) { if (a->pinned() != b->pinned()) return a->pinned() > b->pinned(); return a->updatedAtMs() > b->updatedAtMs(); }); notify(before); } void ChatStore::notify(const QList& before) { if (before.size() != m_sessions.size()) Q_EMIT countChanged(); bool same = before.size() == m_sessions.size(); for (int i = 0; same && i < m_sessions.size(); ++i) if (before.at(i) != m_sessions.at(i)) same = false; if (!same) Q_EMIT valuesChanged(); } } // namespace ZShell