Add PlantUML and Mermaid diagram rendering

- Detect plantuml/mermaid code fences and render as diagrams
- PlantUML: POST to localhost:8080/svg for SVG output
- Mermaid: GET to localhost:8081/img/ with base64 for JPEG output
  (SVG not usable - Qt doesn't support foreignObject)
- Cache rendered diagrams by content hash
- Toggle between source code and rendered image view
- Copy button for source text or image data
- Fall back to source display on render errors
- Add ClipboardHelper for image clipboard support
- Clear diagram cache on session reset
This commit is contained in:
Levi Neely 2026-08-07 14:59:20 +02:00
parent 2ea6ec1ba7
commit 07232b94df
7 changed files with 599 additions and 67 deletions

View File

@ -81,6 +81,7 @@ if(OLLIE_KF5)
gui/chatblockmodel.cpp gui/chatblockmodel.cpp
gui/thememanager.cpp gui/thememanager.cpp
gui/streamfsm.cpp gui/streamfsm.cpp
gui/clipboardhelper.cpp
${GUI_KF5_QRC} ${GUI_KF5_QRC}
) )
target_include_directories(ollie-gui PRIVATE target_include_directories(ollie-gui PRIVATE
@ -92,6 +93,7 @@ if(OLLIE_KF5)
Qt5::Widgets Qt5::Widgets
Qt5::Quick Qt5::Quick
Qt5::QuickControls2 Qt5::QuickControls2
Qt5::Network
KF5::ConfigCore KF5::ConfigCore
KF5::SyntaxHighlighting KF5::SyntaxHighlighting
$ENV{HOME}/.local/lib/libollie9p.so $ENV{HOME}/.local/lib/libollie9p.so
@ -102,7 +104,7 @@ if(OLLIE_KF5)
else() else()
# --- Qt6 / KF6 --- # --- Qt6 / KF6 ---
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Quick QuickControls2) find_package(Qt6 REQUIRED COMPONENTS Core Widgets Quick QuickControls2 Network)
find_package(KF6 REQUIRED COMPONENTS Runner CoreAddons) find_package(KF6 REQUIRED COMPONENTS Runner CoreAddons)
find_package(KF6Config REQUIRED) find_package(KF6Config REQUIRED)
@ -149,6 +151,7 @@ else()
gui/chatblockmodel.cpp gui/chatblockmodel.cpp
gui/thememanager.cpp gui/thememanager.cpp
gui/streamfsm.cpp gui/streamfsm.cpp
gui/clipboardhelper.cpp
) )
target_include_directories(ollie-gui PRIVATE target_include_directories(ollie-gui PRIVATE
${CMAKE_SOURCE_DIR}/gui ${CMAKE_SOURCE_DIR}/gui
@ -159,6 +162,7 @@ else()
Qt6::Widgets Qt6::Widgets
Qt6::Quick Qt6::Quick
Qt6::QuickControls2 Qt6::QuickControls2
Qt6::Network
KF6::ConfigCore KF6::ConfigCore
KF6::SyntaxHighlighting KF6::SyntaxHighlighting
$ENV{HOME}/.local/lib/libollie9p.so $ENV{HOME}/.local/lib/libollie9p.so

View File

@ -4,6 +4,12 @@
#include <QRegularExpressionMatch> #include <QRegularExpressionMatch>
#include <QApplication> #include <QApplication>
#include <QPalette> #include <QPalette>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
#include <QUrlQuery>
#include <QCryptographicHash>
#include <KSyntaxHighlighting/Repository> #include <KSyntaxHighlighting/Repository>
#include <KSyntaxHighlighting/Definition> #include <KSyntaxHighlighting/Definition>
@ -107,7 +113,10 @@ const QRegularExpression ChatBlockModel::s_fenceCloseRe(
ChatBlockModel::ChatBlockModel(QObject *parent) ChatBlockModel::ChatBlockModel(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
, m_repo(new KSyntaxHighlighting::Repository()) , m_repo(new KSyntaxHighlighting::Repository())
, m_netManager(new QNetworkAccessManager(this))
{ {
connect(m_netManager, &QNetworkAccessManager::finished,
this, &ChatBlockModel::onDiagramReply);
} }
ChatBlockModel::~ChatBlockModel() ChatBlockModel::~ChatBlockModel()
@ -148,10 +157,32 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
return b.content; return b.content;
case RenderedHtmlRole: case RenderedHtmlRole:
return b.renderedHtml; return b.renderedHtml;
case SegmentsRole:
return segmentsToVariant(b.segments);
} }
return {}; return {};
} }
QVariantList ChatBlockModel::segmentsToVariant(const QList<BlockSegment> &segments) const
{
QVariantList result;
for (const BlockSegment &seg : segments) {
QVariantMap m;
m[QStringLiteral("type")] = (seg.type == BlockSegment::Text)
? QStringLiteral("text") : QStringLiteral("diagram");
m[QStringLiteral("html")] = seg.html;
if (seg.type == BlockSegment::Diagram) {
m[QStringLiteral("lang")] = seg.diagramLang;
m[QStringLiteral("source")] = seg.diagramSource;
m[QStringLiteral("svg")] = seg.diagramSvg;
m[QStringLiteral("error")] = seg.diagramError;
m[QStringLiteral("loading")] = seg.diagramLoading;
}
result.append(m);
}
return result;
}
QHash<int, QByteArray> ChatBlockModel::roleNames() const QHash<int, QByteArray> ChatBlockModel::roleNames() const
{ {
return { return {
@ -159,6 +190,7 @@ QHash<int, QByteArray> ChatBlockModel::roleNames() const
{BlockNameRole, "blockName"}, {BlockNameRole, "blockName"},
{ContentRole, "blockContent"}, {ContentRole, "blockContent"},
{RenderedHtmlRole, "blockRenderedHtml"}, {RenderedHtmlRole, "blockRenderedHtml"},
{SegmentsRole, "blockSegments"},
}; };
} }
@ -204,6 +236,9 @@ void ChatBlockModel::reset()
m_inCodeFence = false; m_inCodeFence = false;
m_fenceLanguage.clear(); m_fenceLanguage.clear();
m_fenceContent.clear(); m_fenceContent.clear();
m_pendingDiagrams.clear();
m_inFlightRequests.clear();
m_diagramCache.clear(); // Clear cache on reset to pick up fixes
endResetModel(); endResetModel();
emit countChanged(); emit countChanged();
} }
@ -223,6 +258,12 @@ void ChatBlockModel::trimFront(int keepCount)
emit countChanged(); emit countChanged();
} }
void ChatBlockModel::clearDiagramCache()
{
m_diagramCache.clear();
qDebug() << "Diagram cache cleared";
}
QString ChatBlockModel::getDefaultTextColor() const QString ChatBlockModel::getDefaultTextColor() const
{ {
const auto textColor = QApplication::palette().color(QPalette::Text); const auto textColor = QApplication::palette().color(QPalette::Text);
@ -286,6 +327,7 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
ChatBlock &block = m_blocks[blockIndex]; ChatBlock &block = m_blocks[blockIndex];
QString html; QString html;
QList<BlockSegment> segments;
// Parse the block content for code fences // Parse the block content for code fences
const QStringList lines = block.content.split('\n'); const QStringList lines = block.content.split('\n');
@ -296,17 +338,102 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
auto flushProse = [&]() { auto flushProse = [&]() {
if (!proseLines.isEmpty()) { if (!proseLines.isEmpty()) {
html += escapeForHtml(proseLines.join('\n')); QString proseHtml = escapeForHtml(proseLines.join('\n'));
html += proseHtml;
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = proseHtml;
segments.append(seg);
proseLines.clear(); proseLines.clear();
} }
}; };
auto flushFence = [&]() { auto flushFence = [&](bool complete) {
if (!fenceLines.isEmpty()) { if (fenceLines.isEmpty()) {
html += highlightCode(fenceLines.join('\n'), fenceLang);
fenceLines.clear();
fenceLang.clear(); fenceLang.clear();
return;
} }
const QString code = fenceLines.join('\n');
if (isDiagramLanguage(fenceLang)) {
BlockSegment seg;
seg.type = BlockSegment::Diagram;
seg.diagramLang = fenceLang;
seg.diagramSource = code;
seg.html = highlightCode(code, fenceLang); // Source view HTML
if (!complete) {
// Streaming — show placeholder, don't request yet
html += diagramPlaceholder(fenceLang);
seg.diagramLoading = true;
} else {
// Complete fence — check cache or request diagram
const QString cacheKey = fenceLang + QStringLiteral(":") +
QString::fromLatin1(QCryptographicHash::hash(
code.toUtf8(), QCryptographicHash::Md5).toHex());
if (m_diagramCache.contains(cacheKey)) {
const QByteArray cached = m_diagramCache.value(cacheKey);
if (cached.startsWith("ERROR:")) {
// Error: fall back to source code rendering (not a diagram segment)
QString codeHtml = highlightCode(code, fenceLang);
html += codeHtml;
BlockSegment textSeg;
textSeg.type = BlockSegment::Text;
textSeg.html = codeHtml;
segments.append(textSeg);
fenceLines.clear();
fenceLang.clear();
return; // Don't append diagram segment
} else {
// Check if JPEG, PNG, or SVG
if (cached.startsWith("JPEG:")) {
QByteArray jpegData = cached.mid(5); // Skip "JPEG:" prefix
html += diagramJpegHtml(jpegData);
seg.diagramSvg = QStringLiteral("data:image/jpeg;base64,") +
QString::fromLatin1(jpegData.toBase64());
} else if (cached.startsWith("PNG:")) {
QByteArray pngData = cached.mid(4); // Skip "PNG:" prefix
html += diagramPngHtml(pngData);
seg.diagramSvg = QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(pngData.toBase64());
} else if (cached.startsWith("SVG:")) {
QByteArray svgData = cached.mid(4); // Skip "SVG:" prefix
html += diagramSvgHtml(svgData);
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(svgData.toBase64());
} else {
// Legacy: assume SVG if no prefix
html += diagramSvgHtml(cached);
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(cached.toBase64());
}
}
} else {
html += diagramPlaceholder(fenceLang);
seg.diagramLoading = true;
// Request diagram asynchronously
requestDiagram(fenceLang, code, blockIndex);
}
}
segments.append(seg);
} else {
QString codeHtml = highlightCode(code, fenceLang);
html += codeHtml;
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = codeHtml;
segments.append(seg);
}
fenceLines.clear();
fenceLang.clear();
}; };
for (const QString &line : lines) { for (const QString &line : lines) {
@ -323,7 +450,7 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
} else { } else {
// Inside fence — check for close // Inside fence — check for close
if (s_fenceCloseRe.match(line).hasMatch()) { if (s_fenceCloseRe.match(line).hasMatch()) {
flushFence(); flushFence(true); // complete
inFence = false; inFence = false;
continue; continue;
} }
@ -333,13 +460,14 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
// Handle unclosed fence (streaming) or remaining prose // Handle unclosed fence (streaming) or remaining prose
if (inFence) { if (inFence) {
// Still inside a fence — render what we have // Still inside a fence — render what we have (incomplete)
html += highlightCode(fenceLines.join('\n'), fenceLang); flushFence(false);
} else { } else {
flushProse(); flushProse();
} }
block.renderedHtml = html; block.renderedHtml = html;
block.segments = segments;
} }
void ChatBlockModel::appendContent(const QString &line) void ChatBlockModel::appendContent(const QString &line)
@ -387,3 +515,154 @@ void ChatBlockModel::processLine(const QString &line)
return; return;
} }
} }
// --- Diagram rendering ---
bool ChatBlockModel::isDiagramLanguage(const QString &lang) const
{
const QString l = lang.toLower();
return l == QStringLiteral("plantuml") ||
l == QStringLiteral("mermaid") ||
l == QStringLiteral("puml");
}
QString ChatBlockModel::diagramPlaceholder(const QString &lang) const
{
const QString defaultColor = getDefaultTextColor();
return QStringLiteral("<div style=\"padding:8px;border:1px dashed ") + defaultColor +
QStringLiteral(";color:") + defaultColor +
QStringLiteral(";font-style:italic;\">Loading ") +
lang.toHtmlEscaped() + QStringLiteral(" diagram...</div>");
}
QString ChatBlockModel::diagramErrorHtml(const QString &error) const
{
return QStringLiteral("<div style=\"padding:8px;border:1px solid red;color:red;\">") +
QStringLiteral("Diagram error: ") + error.toHtmlEscaped() +
QStringLiteral("</div>");
}
QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const
{
// Embed SVG inline as data URI
const QString base64 = QString::fromLatin1(svg.toBase64());
return QStringLiteral("<img src=\"data:image/svg+xml;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
}
QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const
{
// Embed PNG inline as data URI
const QString base64 = QString::fromLatin1(png.toBase64());
return QStringLiteral("<img src=\"data:image/png;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
}
QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const
{
// Embed JPEG inline as data URI
const QString base64 = QString::fromLatin1(jpeg.toBase64());
return QStringLiteral("<img src=\"data:image/jpeg;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
}
void ChatBlockModel::requestDiagram(const QString &lang, const QString &code, int blockIndex)
{
const QString cacheKey = lang + QStringLiteral(":") +
QString::fromLatin1(QCryptographicHash::hash(
code.toUtf8(), QCryptographicHash::Md5).toHex());
// Already have it cached?
if (m_diagramCache.contains(cacheKey))
return;
// Track which blocks need this diagram
if (!m_pendingDiagrams.contains(cacheKey)) {
m_pendingDiagrams[cacheKey] = QList<int>();
}
if (!m_pendingDiagrams[cacheKey].contains(blockIndex)) {
m_pendingDiagrams[cacheKey].append(blockIndex);
}
// Already requesting?
if (m_inFlightRequests.contains(cacheKey))
return;
m_inFlightRequests.insert(cacheKey);
// Build request
QNetworkRequest request;
request.setAttribute(QNetworkRequest::User, cacheKey);
request.setTransferTimeout(10000);
const QString langLower = lang.toLower();
if (langLower == QStringLiteral("plantuml") || langLower == QStringLiteral("puml")) {
// PlantUML server accepts POST with raw source
request.setUrl(QUrl(QStringLiteral("http://localhost:8080/svg")));
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain"));
m_netManager->post(request, code.toUtf8());
} else if (langLower == QStringLiteral("mermaid")) {
// Mermaid-ink expects base64 in URL path, /img/ for PNG output
// (SVG uses foreignObject which Qt's SVG renderer doesn't support)
const QByteArray encoded = code.toUtf8().toBase64();
request.setUrl(QUrl(QStringLiteral("http://localhost:8081/img/") + QString::fromLatin1(encoded)));
m_netManager->get(request);
} else {
m_inFlightRequests.remove(cacheKey);
return;
}
}
void ChatBlockModel::onDiagramReply(QNetworkReply *reply)
{
reply->deleteLater();
const QString cacheKey = reply->request().attribute(QNetworkRequest::User).toString();
m_inFlightRequests.remove(cacheKey);
QByteArray imageData;
bool success = false;
QString errorMsg;
if (reply->error() == QNetworkReply::NoError) {
imageData = reply->readAll();
// Check for JPEG/PNG (mermaid) or SVG (plantuml)
if (imageData.startsWith("\xff\xd8\xff")) {
// JPEG image
success = true;
imageData.prepend("JPEG:");
} else if (imageData.startsWith("\x89PNG")) {
// PNG image
success = true;
imageData.prepend("PNG:");
} else if (imageData.contains("<svg") || imageData.startsWith("<?xml")) {
// SVG image
success = true;
imageData.prepend("SVG:");
} else {
errorMsg = QStringLiteral("Invalid response (not SVG, PNG, or JPEG)");
}
} else {
errorMsg = reply->errorString();
}
// Update cache
if (success) {
m_diagramCache[cacheKey] = imageData;
} else {
// Cache error as empty data with special marker
m_diagramCache[cacheKey] = QByteArray("ERROR:") + errorMsg.toUtf8();
}
// Rebuild all blocks waiting for this diagram
const QList<int> blocks = m_pendingDiagrams.take(cacheKey);
for (int blockIndex : blocks) {
if (blockIndex < m_blocks.size()) {
rebuildBlockHtml(blockIndex);
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
}
}
}

View File

@ -6,11 +6,29 @@
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QRegularExpression> #include <QRegularExpression>
#include <QHash>
#include <QSet>
#include <QVariantList>
class QNetworkAccessManager;
class QNetworkReply;
namespace KSyntaxHighlighting { namespace KSyntaxHighlighting {
class Repository; class Repository;
} }
// A segment within a block — either text or a diagram
struct BlockSegment {
enum Type { Text, Diagram };
Type type = Text;
QString html; // For Text: rendered HTML; for Diagram: highlighted source HTML
QString diagramLang; // For Diagram: language (plantuml, mermaid)
QString diagramSource; // For Diagram: raw source code
QString diagramSvg; // For Diagram: base64 SVG data URI, or empty if loading/error
QString diagramError; // For Diagram: error message if failed
bool diagramLoading = false; // For Diagram: true if still loading
};
struct ChatBlock { struct ChatBlock {
enum Type { enum Type {
User, User,
@ -28,7 +46,8 @@ struct ChatBlock {
Type type = Default; Type type = Default;
QString name; QString name;
QString content; QString content;
QString renderedHtml; // Rendered HTML with syntax highlighting QString renderedHtml; // Legacy: full HTML (kept for compatibility)
QList<BlockSegment> segments; // Structured segments for interactive rendering
}; };
class ChatBlockModel : public QAbstractListModel class ChatBlockModel : public QAbstractListModel
@ -48,6 +67,7 @@ public:
BlockNameRole, BlockNameRole,
ContentRole, ContentRole,
RenderedHtmlRole, RenderedHtmlRole,
SegmentsRole, // QVariantList of segment maps
}; };
explicit ChatBlockModel(QObject *parent = nullptr); explicit ChatBlockModel(QObject *parent = nullptr);
@ -60,10 +80,14 @@ public:
Q_INVOKABLE void appendData(const QString &text); Q_INVOKABLE void appendData(const QString &text);
Q_INVOKABLE void reset(); Q_INVOKABLE void reset();
Q_INVOKABLE void trimFront(int keepCount); Q_INVOKABLE void trimFront(int keepCount);
Q_INVOKABLE void clearDiagramCache();
signals: signals:
void countChanged(); void countChanged();
private slots:
void onDiagramReply(QNetworkReply *reply);
private: private:
void processLine(const QString &line); void processLine(const QString &line);
void appendContent(const QString &line); void appendContent(const QString &line);
@ -72,9 +96,22 @@ private:
QString highlightCode(const QString &code, const QString &language) const; QString highlightCode(const QString &code, const QString &language) const;
QString escapeForHtml(const QString &text) const; QString escapeForHtml(const QString &text) const;
QString getDefaultTextColor() const; QString getDefaultTextColor() const;
// Diagram rendering
bool isDiagramLanguage(const QString &lang) const;
void requestDiagram(const QString &lang, const QString &code, int blockIndex);
QString diagramPlaceholder(const QString &lang) const;
QString diagramErrorHtml(const QString &error) const;
QString diagramSvgHtml(const QByteArray &svg) const;
QString diagramPngHtml(const QByteArray &png) const;
QString diagramJpegHtml(const QByteArray &jpeg) const;
// Convert segments to QVariantList for QML
QVariantList segmentsToVariant(const QList<BlockSegment> &segments) const;
State m_state = State::BlockStart; State m_state = State::BlockStart;
KSyntaxHighlighting::Repository *m_repo = nullptr; KSyntaxHighlighting::Repository *m_repo = nullptr;
QNetworkAccessManager *m_netManager = nullptr;
QList<ChatBlock> m_blocks; QList<ChatBlock> m_blocks;
QString m_lineBuffer; QString m_lineBuffer;
@ -83,6 +120,13 @@ private:
bool m_inCodeFence = false; bool m_inCodeFence = false;
QString m_fenceLanguage; QString m_fenceLanguage;
QString m_fenceContent; QString m_fenceContent;
// Diagram cache: hash of (lang + code) -> SVG data
QHash<QString, QByteArray> m_diagramCache;
// Pending requests: hash -> block indices waiting for this diagram
QHash<QString, QList<int>> m_pendingDiagrams;
// Track which requests are in flight
QSet<QString> m_inFlightRequests;
static const QRegularExpression s_headerRe; static const QRegularExpression s_headerRe;
static const QRegularExpression s_endRe; static const QRegularExpression s_endRe;

1
gui/clipboardhelper.cpp Normal file
View File

@ -0,0 +1 @@
#include "clipboardhelper.h"

59
gui/clipboardhelper.h Normal file
View File

@ -0,0 +1,59 @@
#ifndef CLIPBOARDHELPER_H
#define CLIPBOARDHELPER_H
#include <QObject>
#include <QClipboard>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
class ClipboardHelper : public QObject
{
Q_OBJECT
public:
explicit ClipboardHelper(QObject *parent = nullptr) : QObject(parent) {}
// Copy image from data URI (data:image/svg+xml;base64,...) to clipboard
Q_INVOKABLE void copyImage(const QString &dataUri) {
if (!dataUri.startsWith(QStringLiteral("data:image/")))
return;
// Parse data URI: data:image/TYPE;base64,DATA
int commaIdx = dataUri.indexOf(',');
if (commaIdx < 0) return;
QString header = dataUri.left(commaIdx);
QString base64Data = dataUri.mid(commaIdx + 1);
QByteArray imageData = QByteArray::fromBase64(base64Data.toLatin1());
QClipboard *clipboard = QGuiApplication::clipboard();
QMimeData *mimeData = new QMimeData();
if (header.contains(QStringLiteral("svg"))) {
// For SVG, set as both image/svg+xml and try to render as raster
mimeData->setData(QStringLiteral("image/svg+xml"), imageData);
// Also try to load as QImage for apps that don't support SVG
QImage img;
if (img.loadFromData(imageData, "SVG")) {
mimeData->setImageData(img);
}
} else {
// PNG, JPEG, etc.
QImage img;
if (img.loadFromData(imageData)) {
mimeData->setImageData(img);
}
}
clipboard->setMimeData(mimeData);
}
// Copy plain text to clipboard
Q_INVOKABLE void copyText(const QString &text) {
QGuiApplication::clipboard()->setText(text);
}
};
#endif // CLIPBOARDHELPER_H

View File

@ -8,6 +8,7 @@
#include "ollie9pclient.h" #include "ollie9pclient.h"
#include "chatblockmodel.h" #include "chatblockmodel.h"
#include "thememanager.h" #include "thememanager.h"
#include "clipboardhelper.h"
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
@ -27,6 +28,7 @@ int main(int argc, char *argv[])
auto *ollie = new Ollie9pClient(&app); auto *ollie = new Ollie9pClient(&app);
auto *chatModel = new ChatBlockModel(&app); auto *chatModel = new ChatBlockModel(&app);
auto *theme = new ThemeManager(&app); auto *theme = new ThemeManager(&app);
auto *clipboard = new ClipboardHelper(&app);
// Route chat stream to the block model. // Route chat stream to the block model.
// Reset + reload is handled in QML's onActiveSessionIdChanged so the // Reset + reload is handled in QML's onActiveSessionIdChanged so the
@ -37,6 +39,7 @@ int main(int argc, char *argv[])
engine.rootContext()->setContextProperty("ollie", ollie); engine.rootContext()->setContextProperty("ollie", ollie);
engine.rootContext()->setContextProperty("chatModel", chatModel); engine.rootContext()->setContextProperty("chatModel", chatModel);
engine.rootContext()->setContextProperty("theme", theme); engine.rootContext()->setContextProperty("theme", theme);
engine.rootContext()->setContextProperty("clipboard", clipboard);
const QUrl url(QStringLiteral("qrc:/main.qml")); const QUrl url(QStringLiteral("qrc:/main.qml"));

View File

@ -1373,71 +1373,213 @@ property bool blockExpanded: {
} }
} }
// Block content is Markdown rendered by ChatBlockModel. // Block content rendered as segments (text and diagrams)
TextEdit { Column {
id: blockContent
Layout.fillWidth: true Layout.fillWidth: true
visible: blockItem.blockExpanded visible: blockItem.blockExpanded
text: blockRenderedHtml || "" spacing: 4
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
// Note: don't set 'color' here — it can override inline styles from
// syntax highlighting in Qt5. The HTML content should set colors.
// Selection colors Repeater {
selectionColor: theme.highlightColor model: blockSegments || []
selectedTextColor: theme.highlightedTextColor
// Restore ListView scrolling when focus is lost
onActiveFocusChanged: {
if (!activeFocus)
chatView.interactive = true
}
// Context menu for text operations
Menu {
id: textContextMenu
MenuItem {
text: "Copy"
enabled: blockContent.selectedText.length > 0
onTriggered: blockContent.copy()
}
MenuItem {
text: "Select All"
onTriggered: blockContent.selectAll()
}
}
// Mouse handling for selection (left button)
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
propagateComposedEvents: true
cursorShape: Qt.IBeamCursor
onPressed: function(mouse) { delegate: Loader {
chatView.interactive = false width: parent.width
mouse.accepted = false // Let TextEdit handle selection sourceComponent: modelData.type === "diagram" ? diagramComponent : textComponent
}
onReleased: function(mouse) { property var segmentData: modelData
chatView.interactive = true
mouse.accepted = false
} }
} }
}
// Text segment component
Component {
id: textComponent
// Context menu (right button) - separate to avoid deselection TextEdit {
MouseArea { width: parent ? parent.width : 100
anchors.fill: parent text: segmentData.html || ""
acceptedButtons: Qt.RightButton textFormat: Text.RichText
cursorShape: Qt.IBeamCursor readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
onClicked: function(mouse) { onActiveFocusChanged: {
textContextMenu.popup() if (!activeFocus)
chatView.interactive = true
}
Menu {
id: textSegmentMenu
MenuItem {
text: "Copy"
enabled: parent.parent.selectedText.length > 0
onTriggered: parent.parent.copy()
}
MenuItem {
text: "Select All"
onTriggered: parent.parent.selectAll()
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
propagateComposedEvents: true
cursorShape: Qt.IBeamCursor
onPressed: function(mouse) {
chatView.interactive = false
mouse.accepted = false
}
onReleased: function(mouse) {
chatView.interactive = true
mouse.accepted = false
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: textSegmentMenu.popup()
}
}
}
// Diagram segment component with source/image toggle
Component {
id: diagramComponent
Rectangle {
width: parent ? parent.width : 100
height: diagramContent.height + diagramHeader.height + 8
color: "transparent"
border.color: theme.midColor
border.width: 1
radius: 4
property bool showSource: false
// Header with language label and toggle button
RowLayout {
id: diagramHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: 24
Label {
text: segmentData.lang || "diagram"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 11
color: theme.textColor
opacity: 0.7
}
Item { Layout.fillWidth: true }
Button {
text: parent.parent.showSource ? "📊" : "</>"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: parent.parent.showSource ? "Show diagram" : "Show source"
onClicked: parent.parent.showSource = !parent.parent.showSource
}
Button {
text: "📋"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: parent.parent.showSource ? "Copy source" : "Copy image"
onClicked: {
if (parent.parent.showSource) {
// Copy source code as plain text
clipboard.copyText(segmentData.source)
} else if (segmentData.svg) {
// Copy image
clipboard.copyImage(segmentData.svg)
}
}
}
}
// Content area
Item {
id: diagramContent
anchors.top: diagramHeader.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: showSource ? diagramSourceEdit.height : diagramImage.height
// Source view
TextEdit {
id: diagramSourceEdit
width: parent.width
visible: showSource
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
propagateComposedEvents: true
cursorShape: Qt.IBeamCursor
onPressed: function(mouse) {
chatView.interactive = false
mouse.accepted = false
}
onReleased: function(mouse) {
chatView.interactive = true
mouse.accepted = false
}
}
}
// Image view
Column {
id: diagramImage
width: parent.width
visible: !showSource
Rectangle {
visible: segmentData.svg && !segmentData.loading
color: "white"
width: diagramImg.width
height: diagramImg.height
Image {
id: diagramImg
source: segmentData.svg || ""
fillMode: Image.PreserveAspectFit
width: Math.min(implicitWidth, diagramImage.width)
}
}
Label {
visible: segmentData.loading
text: "Loading " + (segmentData.lang || "diagram") + "..."
font.italic: true
color: theme.textColor
opacity: 0.7
}
}
} }
} }
} }