streaming INI parser: ChatBlockModel with per-block decorations
This commit is contained in:
parent
b28921c39f
commit
857a919b64
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
@ -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
|
||||
16
gui/main.cpp
16
gui/main.cpp
|
|
@ -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())
|
||||
|
|
|
|||
122
gui/main.qml
122
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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue