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:
parent
2ea6ec1ba7
commit
07232b94df
|
|
@ -81,6 +81,7 @@ if(OLLIE_KF5)
|
|||
gui/chatblockmodel.cpp
|
||||
gui/thememanager.cpp
|
||||
gui/streamfsm.cpp
|
||||
gui/clipboardhelper.cpp
|
||||
${GUI_KF5_QRC}
|
||||
)
|
||||
target_include_directories(ollie-gui PRIVATE
|
||||
|
|
@ -92,6 +93,7 @@ if(OLLIE_KF5)
|
|||
Qt5::Widgets
|
||||
Qt5::Quick
|
||||
Qt5::QuickControls2
|
||||
Qt5::Network
|
||||
KF5::ConfigCore
|
||||
KF5::SyntaxHighlighting
|
||||
$ENV{HOME}/.local/lib/libollie9p.so
|
||||
|
|
@ -102,7 +104,7 @@ if(OLLIE_KF5)
|
|||
|
||||
else()
|
||||
# --- 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(KF6Config REQUIRED)
|
||||
|
||||
|
|
@ -149,6 +151,7 @@ else()
|
|||
gui/chatblockmodel.cpp
|
||||
gui/thememanager.cpp
|
||||
gui/streamfsm.cpp
|
||||
gui/clipboardhelper.cpp
|
||||
)
|
||||
target_include_directories(ollie-gui PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/gui
|
||||
|
|
@ -159,6 +162,7 @@ else()
|
|||
Qt6::Widgets
|
||||
Qt6::Quick
|
||||
Qt6::QuickControls2
|
||||
Qt6::Network
|
||||
KF6::ConfigCore
|
||||
KF6::SyntaxHighlighting
|
||||
$ENV{HOME}/.local/lib/libollie9p.so
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
#include <QRegularExpressionMatch>
|
||||
#include <QApplication>
|
||||
#include <QPalette>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QCryptographicHash>
|
||||
|
||||
#include <KSyntaxHighlighting/Repository>
|
||||
#include <KSyntaxHighlighting/Definition>
|
||||
|
|
@ -107,7 +113,10 @@ const QRegularExpression ChatBlockModel::s_fenceCloseRe(
|
|||
ChatBlockModel::ChatBlockModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
, m_repo(new KSyntaxHighlighting::Repository())
|
||||
, m_netManager(new QNetworkAccessManager(this))
|
||||
{
|
||||
connect(m_netManager, &QNetworkAccessManager::finished,
|
||||
this, &ChatBlockModel::onDiagramReply);
|
||||
}
|
||||
|
||||
ChatBlockModel::~ChatBlockModel()
|
||||
|
|
@ -148,10 +157,32 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
|
|||
return b.content;
|
||||
case RenderedHtmlRole:
|
||||
return b.renderedHtml;
|
||||
case SegmentsRole:
|
||||
return segmentsToVariant(b.segments);
|
||||
}
|
||||
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
|
||||
{
|
||||
return {
|
||||
|
|
@ -159,6 +190,7 @@ QHash<int, QByteArray> ChatBlockModel::roleNames() const
|
|||
{BlockNameRole, "blockName"},
|
||||
{ContentRole, "blockContent"},
|
||||
{RenderedHtmlRole, "blockRenderedHtml"},
|
||||
{SegmentsRole, "blockSegments"},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +236,9 @@ void ChatBlockModel::reset()
|
|||
m_inCodeFence = false;
|
||||
m_fenceLanguage.clear();
|
||||
m_fenceContent.clear();
|
||||
m_pendingDiagrams.clear();
|
||||
m_inFlightRequests.clear();
|
||||
m_diagramCache.clear(); // Clear cache on reset to pick up fixes
|
||||
endResetModel();
|
||||
emit countChanged();
|
||||
}
|
||||
|
|
@ -223,6 +258,12 @@ void ChatBlockModel::trimFront(int keepCount)
|
|||
emit countChanged();
|
||||
}
|
||||
|
||||
void ChatBlockModel::clearDiagramCache()
|
||||
{
|
||||
m_diagramCache.clear();
|
||||
qDebug() << "Diagram cache cleared";
|
||||
}
|
||||
|
||||
QString ChatBlockModel::getDefaultTextColor() const
|
||||
{
|
||||
const auto textColor = QApplication::palette().color(QPalette::Text);
|
||||
|
|
@ -286,6 +327,7 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
|
||||
ChatBlock &block = m_blocks[blockIndex];
|
||||
QString html;
|
||||
QList<BlockSegment> segments;
|
||||
|
||||
// Parse the block content for code fences
|
||||
const QStringList lines = block.content.split('\n');
|
||||
|
|
@ -296,17 +338,102 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
|
||||
auto flushProse = [&]() {
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
auto flushFence = [&]() {
|
||||
if (!fenceLines.isEmpty()) {
|
||||
html += highlightCode(fenceLines.join('\n'), fenceLang);
|
||||
auto flushFence = [&](bool complete) {
|
||||
if (fenceLines.isEmpty()) {
|
||||
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) {
|
||||
|
|
@ -323,7 +450,7 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
} else {
|
||||
// Inside fence — check for close
|
||||
if (s_fenceCloseRe.match(line).hasMatch()) {
|
||||
flushFence();
|
||||
flushFence(true); // complete
|
||||
inFence = false;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -333,13 +460,14 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
|
||||
// Handle unclosed fence (streaming) or remaining prose
|
||||
if (inFence) {
|
||||
// Still inside a fence — render what we have
|
||||
html += highlightCode(fenceLines.join('\n'), fenceLang);
|
||||
// Still inside a fence — render what we have (incomplete)
|
||||
flushFence(false);
|
||||
} else {
|
||||
flushProse();
|
||||
}
|
||||
|
||||
block.renderedHtml = html;
|
||||
block.segments = segments;
|
||||
}
|
||||
|
||||
void ChatBlockModel::appendContent(const QString &line)
|
||||
|
|
@ -387,3 +515,154 @@ void ChatBlockModel::processLine(const QString &line)
|
|||
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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,29 @@
|
|||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QRegularExpression>
|
||||
#include <QHash>
|
||||
#include <QSet>
|
||||
#include <QVariantList>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
|
||||
namespace KSyntaxHighlighting {
|
||||
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 {
|
||||
enum Type {
|
||||
User,
|
||||
|
|
@ -28,7 +46,8 @@ struct ChatBlock {
|
|||
Type type = Default;
|
||||
QString name;
|
||||
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
|
||||
|
|
@ -48,6 +67,7 @@ public:
|
|||
BlockNameRole,
|
||||
ContentRole,
|
||||
RenderedHtmlRole,
|
||||
SegmentsRole, // QVariantList of segment maps
|
||||
};
|
||||
|
||||
explicit ChatBlockModel(QObject *parent = nullptr);
|
||||
|
|
@ -60,10 +80,14 @@ public:
|
|||
Q_INVOKABLE void appendData(const QString &text);
|
||||
Q_INVOKABLE void reset();
|
||||
Q_INVOKABLE void trimFront(int keepCount);
|
||||
Q_INVOKABLE void clearDiagramCache();
|
||||
|
||||
signals:
|
||||
void countChanged();
|
||||
|
||||
private slots:
|
||||
void onDiagramReply(QNetworkReply *reply);
|
||||
|
||||
private:
|
||||
void processLine(const QString &line);
|
||||
void appendContent(const QString &line);
|
||||
|
|
@ -73,8 +97,21 @@ private:
|
|||
QString escapeForHtml(const QString &text) 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;
|
||||
KSyntaxHighlighting::Repository *m_repo = nullptr;
|
||||
QNetworkAccessManager *m_netManager = nullptr;
|
||||
|
||||
QList<ChatBlock> m_blocks;
|
||||
QString m_lineBuffer;
|
||||
|
|
@ -84,6 +121,13 @@ private:
|
|||
QString m_fenceLanguage;
|
||||
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_endRe;
|
||||
static const QRegularExpression s_fenceOpenRe;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
#include "clipboardhelper.h"
|
||||
|
|
@ -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
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
#include "ollie9pclient.h"
|
||||
#include "chatblockmodel.h"
|
||||
#include "thememanager.h"
|
||||
#include "clipboardhelper.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
|
|
@ -27,6 +28,7 @@ int main(int argc, char *argv[])
|
|||
auto *ollie = new Ollie9pClient(&app);
|
||||
auto *chatModel = new ChatBlockModel(&app);
|
||||
auto *theme = new ThemeManager(&app);
|
||||
auto *clipboard = new ClipboardHelper(&app);
|
||||
|
||||
// Route chat stream to the block model.
|
||||
// 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("chatModel", chatModel);
|
||||
engine.rootContext()->setContextProperty("theme", theme);
|
||||
engine.rootContext()->setContextProperty("clipboard", clipboard);
|
||||
|
||||
const QUrl url(QStringLiteral("qrc:/main.qml"));
|
||||
|
||||
|
|
|
|||
182
gui/main.qml
182
gui/main.qml
|
|
@ -1373,12 +1373,31 @@ property bool blockExpanded: {
|
|||
}
|
||||
}
|
||||
|
||||
// Block content is Markdown rendered by ChatBlockModel.
|
||||
TextEdit {
|
||||
id: blockContent
|
||||
// Block content rendered as segments (text and diagrams)
|
||||
Column {
|
||||
Layout.fillWidth: true
|
||||
visible: blockItem.blockExpanded
|
||||
text: blockRenderedHtml || ""
|
||||
spacing: 4
|
||||
|
||||
Repeater {
|
||||
model: blockSegments || []
|
||||
|
||||
delegate: Loader {
|
||||
width: parent.width
|
||||
sourceComponent: modelData.type === "diagram" ? diagramComponent : textComponent
|
||||
|
||||
property var segmentData: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text segment component
|
||||
Component {
|
||||
id: textComponent
|
||||
|
||||
TextEdit {
|
||||
width: parent ? parent.width : 100
|
||||
text: segmentData.html || ""
|
||||
textFormat: Text.RichText
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
|
|
@ -1386,43 +1405,35 @@ property bool blockExpanded: {
|
|||
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
|
||||
selectionColor: theme.highlightColor
|
||||
selectedTextColor: theme.highlightedTextColor
|
||||
|
||||
// Restore ListView scrolling when focus is lost
|
||||
onActiveFocusChanged: {
|
||||
if (!activeFocus)
|
||||
chatView.interactive = true
|
||||
}
|
||||
|
||||
// Context menu for text operations
|
||||
Menu {
|
||||
id: textContextMenu
|
||||
id: textSegmentMenu
|
||||
MenuItem {
|
||||
text: "Copy"
|
||||
enabled: blockContent.selectedText.length > 0
|
||||
onTriggered: blockContent.copy()
|
||||
enabled: parent.parent.selectedText.length > 0
|
||||
onTriggered: parent.parent.copy()
|
||||
}
|
||||
MenuItem {
|
||||
text: "Select All"
|
||||
onTriggered: blockContent.selectAll()
|
||||
onTriggered: parent.parent.selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse handling for selection (left button)
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton
|
||||
propagateComposedEvents: true
|
||||
cursorShape: Qt.IBeamCursor
|
||||
|
||||
onPressed: function(mouse) {
|
||||
chatView.interactive = false
|
||||
mouse.accepted = false // Let TextEdit handle selection
|
||||
mouse.accepted = false
|
||||
}
|
||||
onReleased: function(mouse) {
|
||||
chatView.interactive = true
|
||||
|
|
@ -1430,14 +1441,145 @@ property bool blockExpanded: {
|
|||
}
|
||||
}
|
||||
|
||||
// Context menu (right button) - separate to avoid deselection
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
cursorShape: Qt.IBeamCursor
|
||||
onClicked: textSegmentMenu.popup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: function(mouse) {
|
||||
textContextMenu.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue