streaming INI parser: ChatBlockModel with per-block decorations

This commit is contained in:
Levi Neely 2026-08-01 09:37:48 +02:00
parent b28921c39f
commit 857a919b64
5 changed files with 279 additions and 36 deletions

View File

@ -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

117
gui/chatblockmodel.cpp Normal file
View File

@ -0,0 +1,117 @@
#include "chatblockmodel.h"
#include <QRegularExpressionMatch>
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<int, QByteArray> 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});
}

59
gui/chatblockmodel.h Normal file
View File

@ -0,0 +1,59 @@
#ifndef CHATBLOCKMODEL_H
#define CHATBLOCKMODEL_H
#include <QAbstractListModel>
#include <QList>
#include <QString>
#include <QRegularExpression>
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<int, QByteArray> roleNames() const override;
Q_INVOKABLE void appendData(const QString &text);
Q_INVOKABLE void reset();
signals:
void countChanged();
private:
void processLine(const QString &line);
QList<ChatBlock> m_blocks;
QString m_lineBuffer;
static const QRegularExpression s_headerRe;
};
#endif // CHATBLOCKMODEL_H

View File

@ -6,6 +6,7 @@
#include <QQuickStyle>
#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())

View File

@ -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 {
}
}
}
}
}