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/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
|
||||||
|
|
|
||||||
|
|
@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 "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"));
|
||||||
|
|
||||||
|
|
|
||||||
256
gui/main.qml
256
gui/main.qml
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in New Issue