From 857a919b642e939b981831b0494a59094df221ac Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:37:48 +0200 Subject: [PATCH] streaming INI parser: ChatBlockModel with per-block decorations --- CMakeLists.txt | 1 + gui/chatblockmodel.cpp | 117 +++++++++++++++++++++++++++++++++++++++ gui/chatblockmodel.h | 59 ++++++++++++++++++++ gui/main.cpp | 16 ++++-- gui/main.qml | 122 +++++++++++++++++++++++++++++++---------- 5 files changed, 279 insertions(+), 36 deletions(-) create mode 100644 gui/chatblockmodel.cpp create mode 100644 gui/chatblockmodel.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d427da2..dcda4b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -134,6 +134,7 @@ else() add_executable(ollie-gui gui/main.cpp gui/ollie9pclient.cpp + gui/chatblockmodel.cpp ) target_include_directories(ollie-gui PRIVATE ${CMAKE_SOURCE_DIR}/gui diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp new file mode 100644 index 0000000..27668db --- /dev/null +++ b/gui/chatblockmodel.cpp @@ -0,0 +1,117 @@ +#include "chatblockmodel.h" + +#include + +const QRegularExpression ChatBlockModel::s_headerRe( + R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\]$)"); + +ChatBlockModel::ChatBlockModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int ChatBlockModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) return 0; + return m_blocks.size(); +} + +QVariant ChatBlockModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= m_blocks.size()) + return {}; + + const ChatBlock &b = m_blocks[index.row()]; + switch (role) { + case BlockTypeRole: + switch (b.type) { + case ChatBlock::User: return QStringLiteral("user"); + case ChatBlock::Assistant: return QStringLiteral("assistant"); + case ChatBlock::Reasoning: return QStringLiteral("reasoning"); + case ChatBlock::Call: return QStringLiteral("call"); + case ChatBlock::Tool: return QStringLiteral("tool"); + case ChatBlock::Error: return QStringLiteral("error"); + case ChatBlock::Info: return QStringLiteral("info"); + case ChatBlock::Default: return QStringLiteral("default"); + } + return {}; + case BlockNameRole: + return b.name; + case ContentRole: + return b.content; + } + return {}; +} + +QHash ChatBlockModel::roleNames() const +{ + return { + {BlockTypeRole, "blockType"}, + {BlockNameRole, "blockName"}, + {ContentRole, "blockContent"}, + }; +} + +void ChatBlockModel::appendData(const QString &text) +{ + m_lineBuffer += text; + + int idx; + while ((idx = m_lineBuffer.indexOf('\n')) >= 0) { + QString line = m_lineBuffer.left(idx); + m_lineBuffer = m_lineBuffer.mid(idx + 1); + processLine(line); + } +} + +void ChatBlockModel::reset() +{ + beginResetModel(); + m_blocks.clear(); + m_lineBuffer.clear(); + endResetModel(); + emit countChanged(); +} + +void ChatBlockModel::processLine(const QString &line) +{ + QRegularExpressionMatch m = s_headerRe.match(line); + if (m.hasMatch()) { + // Header line — finalize current block, start new one + QString typeStr = m.captured(1); + QString qualifier = m.captured(2); + + ChatBlock::Type type; + if (typeStr == "user") type = ChatBlock::User; + else if (typeStr == "assistant") type = ChatBlock::Assistant; + else if (typeStr == "reasoning") type = ChatBlock::Reasoning; + else if (typeStr == "call") type = ChatBlock::Call; + else if (typeStr == "tool") type = ChatBlock::Tool; + else if (typeStr == "error") type = ChatBlock::Error; + else if (typeStr == "info") type = ChatBlock::Info; + else type = ChatBlock::Default; + + beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); + m_blocks.append({type, qualifier, QString()}); + endInsertRows(); + emit countChanged(); + return; + } + + // Content line — append to the last block if any + if (m_blocks.isEmpty()) { + // Mid-stream connect: create a Default block + beginInsertRows(QModelIndex(), 0, 0); + m_blocks.append({ChatBlock::Default, QString(), QString()}); + endInsertRows(); + emit countChanged(); + } + + ChatBlock &last = m_blocks.last(); + if (!last.content.isEmpty()) + last.content += '\n'; + last.content += line; + + QModelIndex idx = index(m_blocks.size() - 1); + emit dataChanged(idx, idx, {ContentRole}); +} \ No newline at end of file diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h new file mode 100644 index 0000000..11ffc7f --- /dev/null +++ b/gui/chatblockmodel.h @@ -0,0 +1,59 @@ +#ifndef CHATBLOCKMODEL_H +#define CHATBLOCKMODEL_H + +#include +#include +#include +#include + +struct ChatBlock { + enum Type { + User, + Assistant, + Reasoning, + Call, + Tool, + Error, + Info, + Default + }; + + Type type = Default; + QString name; + QString content; +}; + +class ChatBlockModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) + +public: + enum Roles { + BlockTypeRole = Qt::UserRole + 1, + BlockNameRole, + ContentRole, + }; + + explicit ChatBlockModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + Q_INVOKABLE void appendData(const QString &text); + Q_INVOKABLE void reset(); + +signals: + void countChanged(); + +private: + void processLine(const QString &line); + + QList m_blocks; + QString m_lineBuffer; + + static const QRegularExpression s_headerRe; +}; + +#endif // CHATBLOCKMODEL_H diff --git a/gui/main.cpp b/gui/main.cpp index a55c2c3..6cd9071 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -6,6 +6,7 @@ #include #include "ollie9pclient.h" +#include "chatblockmodel.h" int main(int argc, char *argv[]) { @@ -23,13 +24,16 @@ int main(int argc, char *argv[]) QQmlApplicationEngine engine; auto *ollie = new Ollie9pClient(&app); - engine.rootContext()->setContextProperty("ollie", ollie); + auto *chatModel = new ChatBlockModel(&app); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - const QUrl url(QStringLiteral("qrc:/gui/main-9p.qml")); -#else - const QUrl url(QStringLiteral("qrc:/main.qml")); -#endif + // Route chat stream to the block model + QObject::connect(ollie, &Ollie9pClient::chatReceived, chatModel, &ChatBlockModel::appendData); + QObject::connect(ollie, &Ollie9pClient::activeSessionIdChanged, chatModel, &ChatBlockModel::reset); + + engine.rootContext()->setContextProperty("ollie", ollie); + engine.rootContext()->setContextProperty("chatModel", chatModel); + +const QUrl url(QStringLiteral("qrc:/main.qml")); engine.load(url); if (engine.rootObjects().isEmpty()) diff --git a/gui/main.qml b/gui/main.qml index d8816d8..d74ac09 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -9,36 +9,21 @@ ApplicationWindow { height: 600 title: "Ollie" - // Chat tail buffer — only keep last 8KB for display - property string chatTail: "" - readonly property int maxTail: 8192 - Component.onCompleted: { // Load initial log content var log = ollie.readLog() - if (log.length > maxTail) - log = log.slice(-maxTail) - chatTail = log + if (log) chatModel.appendData(log) } Connections { target: ollie - function onChatReceived(text) { - chatTail += text - if (chatTail.length > maxTail * 2) { - chatTail = chatTail.slice(-maxTail) - } - } function onActiveSessionIdChanged() { - chatTail = "" + chatModel.reset() var log = ollie.readLog() - if (log.length > maxTail) - log = log.slice(-maxTail) - chatTail = log + if (log) chatModel.appendData(log) } function onStreamingDone() { - // Session was killed - chatTail += "\n[session ended]\n" + chatModel.appendData("\n[info]\nsession ended\n") } } @@ -57,7 +42,7 @@ ApplicationWindow { anchors.fill: parent Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 } ToolButton { - text: "↻" + text: "\u21BB" onClicked: ollie.refreshSessions() } } @@ -127,19 +112,96 @@ ApplicationWindow { Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } - // Chat display — streaming text, last 8KB + // Chat display — block-based ListView ScrollView { Layout.fillWidth: true Layout.fillHeight: true + clip: true - TextArea { - id: chatArea - readOnly: true - wrapMode: TextEdit.NoWrap - text: root.chatTail - font.family: "monospace" - font.pixelSize: 12 - onTextChanged: cursorPosition = text.length + ListView { + id: chatView + model: chatModel + spacing: 4 + anchors.fill: parent + anchors.margins: 4 + boundsBehavior: Flickable.StopAtBounds + + onCountChanged: { + // Auto-scroll to bottom on new blocks + if (count > 0) { + positionViewAtEnd() + } + } + + Connections { + target: chatModel + function onDataChanged() { + // Keep scrolled to bottom when content streams in + if (chatView.atYEnd) + chatView.positionViewAtEnd() + } + } + + delegate: ColumnLayout { + width: chatView.width - 8 + spacing: 2 + + // Header decoration + Rectangle { + Layout.fillWidth: true + height: 22 + radius: 3 + color: { + switch (blockType) { + case "user": return "#e3f2fd" + case "assistant": return "#f3e5f5" + case "reasoning": return "#fff3e0" + case "call": return "#e8f5e9" + case "tool": return "#fce4ec" + case "error": return "#ffebee" + case "info": return "#f5f5f5" + default: return "#fafafa" + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + spacing: 4 + + Label { + text: { + switch (blockType) { + case "user": return "User" + case "assistant": return "Assistant" + (blockName ? " (" + blockName + ")" : "") + case "reasoning": return "Reasoning" + case "call": return "Call: " + blockName + case "tool": return "Tool: " + blockName + case "error": return "Error" + case "info": return "Info" + default: return "" + } + } + font.pixelSize: 11 + font.bold: true + opacity: 0.8 + } + } + } + + // Content + Label { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + Layout.bottomMargin: 4 + text: blockContent || "" + wrapMode: Text.Wrap + textFormat: Text.PlainText + font.family: "monospace" + font.pixelSize: 12 + } + } } } @@ -172,4 +234,4 @@ ApplicationWindow { } } } -} +} \ No newline at end of file