feat(gui): render PlantUML, Mermaid, and draw.io diagrams as images
- Detect mermaid/plantuml/drawio/mxgraph code fences as 'diagram' sub-blocks - Render via local servers (PlantUML :8080, Mermaid :8081, draw.io :8082) - Fall back to CLI tools (plantuml, mmdc, drawio) if servers unavailable - Cache rendered SVGs in ~/.cache/*/diagrams/ keyed by content hash - Show rendered image with white background (dark theme friendly) - Collapsible source toggle (▶/▼) below rendered diagram - Middle-click to invalidate cache and re-render - Left-click opens fullscreen pannable/zoomable viewer - Fullscreen: scroll-wheel zoom, drag to pan, Esc/right-click/dbl-click to close - Both kf5 and kf6 builds
This commit is contained in:
parent
b591640b65
commit
29c74b2837
|
|
@ -39,7 +39,7 @@ endif()
|
|||
|
||||
if(OLLIE_KF5)
|
||||
# --- Qt5 / KF5 ---
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core DBus Widgets Quick Qml QuickControls2)
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core DBus Widgets Quick Qml QuickControls2 Network)
|
||||
|
||||
# Override KDE_INSTALL_PLUGINDIR so kcoreaddons_add_plugin installs under
|
||||
# CMAKE_INSTALL_PREFIX (e.g. ~/.local/lib/qt5/plugins), not the system dir.
|
||||
|
|
@ -137,6 +137,7 @@ if(OLLIE_KF5)
|
|||
Qt5::Quick
|
||||
Qt5::Qml
|
||||
Qt5::QuickControls2
|
||||
Qt5::Network
|
||||
KF5::ConfigCore
|
||||
KF5::SyntaxHighlighting
|
||||
)
|
||||
|
|
@ -150,7 +151,7 @@ if(OLLIE_KF5)
|
|||
|
||||
else()
|
||||
# --- Qt6 / KF6 ---
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick QuickControls2 Multimedia)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick QuickControls2 Multimedia Network)
|
||||
find_package(KF6StatusNotifierItem REQUIRED)
|
||||
find_package(KF6 REQUIRED COMPONENTS Runner Notifications CoreAddons Config)
|
||||
find_package(Plasma REQUIRED)
|
||||
|
|
@ -240,6 +241,7 @@ else()
|
|||
Qt6::Quick
|
||||
Qt6::QuickControls2
|
||||
Qt6::Multimedia
|
||||
Qt6::Network
|
||||
KF6::ConfigCore
|
||||
KF6::SyntaxHighlighting
|
||||
)
|
||||
|
|
|
|||
220
gui/ChatPane.qml
220
gui/ChatPane.qml
|
|
@ -633,6 +633,7 @@ Item {
|
|||
Layout.fillWidth: true
|
||||
property var subBlock: modelData
|
||||
sourceComponent: subBlock.type === "code" ? codeSubDelegate
|
||||
: subBlock.type === "diagram" ? diagramSubDelegate
|
||||
: subBlock.type === "diff" ? diffSubDelegate
|
||||
: subBlock.type === "interrupt" ? interruptSubDelegate
|
||||
: subBlock.type === "video" ? videoSubDelegate
|
||||
|
|
@ -740,6 +741,7 @@ Item {
|
|||
Layout.fillWidth: true
|
||||
property var subBlock: modelData
|
||||
sourceComponent: subBlock.type === "code" ? codeSubDelegate
|
||||
: subBlock.type === "diagram" ? diagramSubDelegate
|
||||
: subBlock.type === "diff" ? diffSubDelegate
|
||||
: subBlock.type === "interrupt" ? interruptSubDelegate
|
||||
: subBlock.type === "video" ? videoSubDelegate
|
||||
|
|
@ -840,6 +842,7 @@ Item {
|
|||
Layout.fillWidth: true
|
||||
property var subBlock: modelData
|
||||
sourceComponent: subBlock.type === "code" ? codeSubDelegate
|
||||
: subBlock.type === "diagram" ? diagramSubDelegate
|
||||
: subBlock.type === "diff" ? diffSubDelegate
|
||||
: subBlock.type === "interrupt" ? interruptSubDelegate
|
||||
: subBlock.type === "video" ? videoSubDelegate
|
||||
|
|
@ -902,6 +905,7 @@ Item {
|
|||
Layout.fillWidth: true
|
||||
property var subBlock: modelData
|
||||
sourceComponent: subBlock.type === "code" ? codeSubDelegate
|
||||
: subBlock.type === "diagram" ? diagramSubDelegate
|
||||
: subBlock.type === "diff" ? diffSubDelegate
|
||||
: subBlock.type === "interrupt" ? interruptSubDelegate
|
||||
: subBlock.type === "video" ? videoSubDelegate
|
||||
|
|
@ -1277,4 +1281,220 @@ Item {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: diagramSubDelegate
|
||||
Rectangle {
|
||||
implicitHeight: diagramCol.implicitHeight
|
||||
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
|
||||
radius: 4
|
||||
border.width: 1
|
||||
border.color: palette.mid
|
||||
|
||||
ColumnLayout {
|
||||
id: diagramCol
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 0
|
||||
|
||||
// Rendered diagram image (white background for dark theme readability)
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 8
|
||||
Layout.maximumHeight: 600
|
||||
implicitHeight: diagramImg.implicitHeight > 0 ? Math.min(diagramImg.implicitHeight, 600) : 100
|
||||
color: "#ffffff"
|
||||
radius: 4
|
||||
|
||||
Image {
|
||||
id: diagramImg
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: paneModel.diagramUrl(subBlock.content || "", subBlock.language || "")
|
||||
asynchronous: true
|
||||
cache: false // we manage caching ourselves
|
||||
|
||||
// Loading indicator
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: diagramImg.status === Image.Loading
|
||||
visible: running
|
||||
}
|
||||
|
||||
// Error fallback
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: "\u26A0 Failed to load diagram"
|
||||
visible: diagramImg.status === Image.Error
|
||||
color: "#666"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function(mouse) {
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
// Middle-click: invalidate cache and re-render
|
||||
paneModel.invalidateDiagram(subBlock.content || "", subBlock.language || "")
|
||||
diagramImg.source = ""
|
||||
diagramImg.source = paneModel.diagramUrl(subBlock.content || "", subBlock.language || "")
|
||||
} else {
|
||||
// Left-click: open fullscreen view
|
||||
diagramFullscreen.diagramSource = diagramImg.source
|
||||
diagramFullscreen.visible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible source toggle
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 4
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
id: diagramToggle
|
||||
property bool expanded: false
|
||||
text: (expanded ? "\u25BC" : "\u25B6") + " " + (subBlock.language || "source")
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.8
|
||||
opacity: 0.6
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: diagramToggle.expanded = !diagramToggle.expanded
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
ToolButton {
|
||||
icon.name: "edit-copy"
|
||||
implicitWidth: 28
|
||||
implicitHeight: 28
|
||||
onClicked: paneModel.copyToClipboard(subBlock.content)
|
||||
ToolTip.text: "Copy source"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsed source code
|
||||
TextEdit {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 8
|
||||
visible: diagramToggle.expanded
|
||||
text: subBlock.content || ""
|
||||
wrapMode: TextEdit.Wrap
|
||||
textFormat: TextEdit.PlainText
|
||||
font.family: "monospace"
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
||||
color: palette.text
|
||||
selectionColor: palette.highlight
|
||||
selectedTextColor: palette.highlightedText
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen diagram viewer (pannable + zoomable)
|
||||
Popup {
|
||||
id: diagramFullscreen
|
||||
property string diagramSource: ""
|
||||
parent: Overlay.overlay
|
||||
anchors.centerIn: parent
|
||||
width: parent ? parent.width * 0.95 : 800
|
||||
height: parent ? parent.height * 0.95 : 600
|
||||
modal: true
|
||||
dim: true
|
||||
padding: 0
|
||||
|
||||
background: Rectangle {
|
||||
color: "#ffffff"
|
||||
radius: 8
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
Shortcut {
|
||||
sequence: "Escape"
|
||||
enabled: diagramFullscreen.visible
|
||||
onActivated: diagramFullscreen.close()
|
||||
}
|
||||
|
||||
// Close button
|
||||
ToolButton {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
z: 10
|
||||
icon.name: "window-close"
|
||||
onClicked: diagramFullscreen.close()
|
||||
}
|
||||
|
||||
// Zoom reset button
|
||||
ToolButton {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.margins: 8
|
||||
z: 10
|
||||
text: Math.round(diagramZoomImg.zoomFactor * 100) + "%"
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.8
|
||||
onClicked: {
|
||||
diagramZoomImg.zoomFactor = 1.0
|
||||
diagramFlick.contentX = 0
|
||||
diagramFlick.contentY = 0
|
||||
}
|
||||
ToolTip.text: "Reset zoom"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: diagramFlick
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
clip: true
|
||||
contentWidth: diagramZoomImg.width
|
||||
contentHeight: diagramZoomImg.height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Image {
|
||||
id: diagramZoomImg
|
||||
source: diagramFullscreen.diagramSource
|
||||
fillMode: Image.PreserveAspectFit
|
||||
cache: false
|
||||
|
||||
property real zoomFactor: 1.0
|
||||
// sourceSize gives us intrinsic dimensions; scale from Flickable size
|
||||
width: Math.max(diagramFlick.width, diagramFlick.width * zoomFactor)
|
||||
height: Math.max(diagramFlick.height, diagramFlick.height * zoomFactor)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: function(mouse) {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
diagramFullscreen.close()
|
||||
}
|
||||
onDoubleClicked: diagramFullscreen.close()
|
||||
onWheel: function(wheel) {
|
||||
let factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87
|
||||
let newZoom = Math.max(0.25, Math.min(10.0, diagramZoomImg.zoomFactor * factor))
|
||||
diagramZoomImg.zoomFactor = newZoom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAboutToShow: {
|
||||
diagramZoomImg.zoomFactor = 1.0
|
||||
diagramFlick.contentX = 0
|
||||
diagramFlick.contentY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,18 @@
|
|||
#include <KSyntaxHighlighting/Format>
|
||||
#include <QGuiApplication>
|
||||
#include <QClipboard>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QFileInfo>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QPalette>
|
||||
#include <QTemporaryFile>
|
||||
|
|
@ -379,6 +387,9 @@ QVariantList ChatBlockModel::parseSubBlocks(const QString &content) const
|
|||
if (lang == "diff" || (lang.isEmpty() &&
|
||||
(code.startsWith("--- ") || code.startsWith("diff --git")))) {
|
||||
type = "diff";
|
||||
} else if (lang.toLower() == "mermaid" || lang.toLower() == "plantuml"
|
||||
|| lang.toLower() == "drawio" || lang.toLower() == "mxgraph") {
|
||||
type = "diagram";
|
||||
}
|
||||
|
||||
QVariantMap m;
|
||||
|
|
@ -742,3 +753,306 @@ bool ChatBlockModel::executeInTerminal(const QString &code, const QString &langu
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// PlantUML text encoding: custom base64 alphabet
|
||||
static QByteArray plantumlEncode64(const QByteArray &data)
|
||||
{
|
||||
static const char table[] =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_";
|
||||
|
||||
QByteArray result;
|
||||
result.reserve((data.size() + 2) / 3 * 4);
|
||||
|
||||
for (int i = 0; i < data.size(); i += 3) {
|
||||
int b0 = static_cast<unsigned char>(data[i]);
|
||||
int b1 = (i + 1 < data.size()) ? static_cast<unsigned char>(data[i + 1]) : 0;
|
||||
int b2 = (i + 2 < data.size()) ? static_cast<unsigned char>(data[i + 2]) : 0;
|
||||
|
||||
result.append(table[b0 >> 2]);
|
||||
result.append(table[((b0 & 0x3) << 4) | (b1 >> 4)]);
|
||||
result.append(table[((b1 & 0xF) << 2) | (b2 >> 6)]);
|
||||
result.append(table[b2 & 0x3F]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Encode PlantUML source for server URL: deflate + custom base64
|
||||
static QByteArray plantumlEncodeSource(const QString &source)
|
||||
{
|
||||
QByteArray utf8 = source.toUtf8();
|
||||
QByteArray compressed = qCompress(utf8);
|
||||
// qCompress: 4-byte Qt length prefix + 2-byte zlib header + deflate + 4-byte adler32
|
||||
if (compressed.size() > 6) {
|
||||
compressed = compressed.mid(6); // strip Qt prefix + zlib header
|
||||
if (compressed.size() > 4)
|
||||
compressed.chop(4); // strip adler32
|
||||
}
|
||||
return plantumlEncode64(compressed);
|
||||
}
|
||||
|
||||
// Fetch SVG from a local PlantUML server, returns true on success
|
||||
static bool fetchFromPlantUmlServer(const QString &source, const QString &svgPath)
|
||||
{
|
||||
QByteArray encoded = plantumlEncodeSource(source);
|
||||
QUrl url(QStringLiteral("http://localhost:8080/svg/") + QString::fromLatin1(encoded));
|
||||
|
||||
QNetworkAccessManager nam;
|
||||
QNetworkRequest req(url);
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
|
||||
QScopedPointer<QNetworkReply> reply(nam.get(req));
|
||||
|
||||
// Synchronous wait with timeout
|
||||
QEventLoop loop;
|
||||
QObject::connect(reply.data(), &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
timer.start(5000); // 5s timeout
|
||||
loop.exec();
|
||||
|
||||
if (!reply->isFinished() || reply->error() != QNetworkReply::NoError)
|
||||
return false;
|
||||
|
||||
QByteArray svg = reply->readAll();
|
||||
if (svg.isEmpty() || !svg.contains("<svg"))
|
||||
return false;
|
||||
|
||||
QFile out(svgPath);
|
||||
if (!out.open(QIODevice::WriteOnly))
|
||||
return false;
|
||||
out.write(svg);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fetch SVG from a local Mermaid server (mermaid.ink API), returns true on success
|
||||
static bool fetchFromMermaidServer(const QString &source, const QString &svgPath)
|
||||
{
|
||||
QByteArray encoded = source.toUtf8().toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
||||
QUrl url(QStringLiteral("http://localhost:8081/svg/") + QString::fromLatin1(encoded));
|
||||
|
||||
QNetworkAccessManager nam;
|
||||
QNetworkRequest req(url);
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
|
||||
QScopedPointer<QNetworkReply> reply(nam.get(req));
|
||||
|
||||
QEventLoop loop;
|
||||
QObject::connect(reply.data(), &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
timer.start(5000);
|
||||
loop.exec();
|
||||
|
||||
if (!reply->isFinished() || reply->error() != QNetworkReply::NoError)
|
||||
return false;
|
||||
|
||||
QByteArray svg = reply->readAll();
|
||||
if (svg.isEmpty() || !svg.contains("<svg"))
|
||||
return false;
|
||||
|
||||
QFile out(svgPath);
|
||||
if (!out.open(QIODevice::WriteOnly))
|
||||
return false;
|
||||
out.write(svg);
|
||||
return true;
|
||||
}
|
||||
|
||||
QString ChatBlockModel::diagramUrl(const QString &source, const QString &language) const
|
||||
{
|
||||
if (source.isEmpty()) return QString();
|
||||
|
||||
QString lang = language.toLower().trimmed();
|
||||
// Normalize mxgraph → drawio
|
||||
if (lang == QStringLiteral("mxgraph"))
|
||||
lang = QStringLiteral("drawio");
|
||||
if (lang != QStringLiteral("plantuml") && lang != QStringLiteral("mermaid")
|
||||
&& lang != QStringLiteral("drawio"))
|
||||
return QString();
|
||||
|
||||
// Deterministic cache path based on content hash
|
||||
QByteArray hash = QCryptographicHash::hash(
|
||||
(lang + "\x1f" + source).toUtf8(), QCryptographicHash::Sha256).toHex().left(16);
|
||||
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
|
||||
+ QStringLiteral("/diagrams");
|
||||
QDir().mkpath(cacheDir);
|
||||
QString svgPath = cacheDir + "/" + QString::fromLatin1(hash) + ".svg";
|
||||
|
||||
// Return cached result if available
|
||||
if (QFileInfo::exists(svgPath))
|
||||
return QUrl::fromLocalFile(svgPath).toString();
|
||||
|
||||
if (lang == QStringLiteral("plantuml")) {
|
||||
// Try local PlantUML server first (localhost:8080)
|
||||
if (fetchFromPlantUmlServer(source, svgPath))
|
||||
return QUrl::fromLocalFile(svgPath).toString();
|
||||
|
||||
// Fall back to CLI
|
||||
QTemporaryFile input;
|
||||
input.setFileTemplate(QStringLiteral("/tmp/ollie-diagram-XXXXXX.puml"));
|
||||
if (!input.open())
|
||||
return QString();
|
||||
input.write(source.toUtf8());
|
||||
input.close();
|
||||
|
||||
QProcess proc;
|
||||
proc.setProcessChannelMode(QProcess::MergedChannels);
|
||||
|
||||
QString plantuml = QStandardPaths::findExecutable(QStringLiteral("plantuml"));
|
||||
if (!plantuml.isEmpty()) {
|
||||
proc.start(plantuml, {
|
||||
QStringLiteral("-tsvg"),
|
||||
QStringLiteral("-o"), cacheDir,
|
||||
QStringLiteral("-filename"), QString::fromLatin1(hash),
|
||||
input.fileName()
|
||||
});
|
||||
} else {
|
||||
QString jar;
|
||||
QStringList jarPaths = {
|
||||
QStringLiteral("/usr/share/plantuml/plantuml.jar"),
|
||||
QStringLiteral("/usr/share/java/plantuml.jar"),
|
||||
QStringLiteral("/opt/plantuml/plantuml.jar"),
|
||||
QDir::homePath() + QStringLiteral("/.local/share/plantuml/plantuml.jar")
|
||||
};
|
||||
for (const QString &p : jarPaths) {
|
||||
if (QFileInfo::exists(p)) { jar = p; break; }
|
||||
}
|
||||
if (jar.isEmpty())
|
||||
return QString();
|
||||
|
||||
proc.start(QStringLiteral("java"), {
|
||||
QStringLiteral("-jar"), jar,
|
||||
QStringLiteral("-tsvg"),
|
||||
QStringLiteral("-o"), cacheDir,
|
||||
QStringLiteral("-filename"), QString::fromLatin1(hash),
|
||||
input.fileName()
|
||||
});
|
||||
}
|
||||
|
||||
if (!proc.waitForFinished(15000))
|
||||
return QString();
|
||||
|
||||
// plantuml names output after input file, rename if needed
|
||||
if (!QFileInfo::exists(svgPath)) {
|
||||
QString baseName = QFileInfo(input.fileName()).completeBaseName();
|
||||
QString actualOutput = cacheDir + "/" + baseName + ".svg";
|
||||
if (QFileInfo::exists(actualOutput))
|
||||
QFile::rename(actualOutput, svgPath);
|
||||
}
|
||||
} else if (lang == QStringLiteral("mermaid")) {
|
||||
// Mermaid — try local server first (localhost:8081), fall back to mmdc CLI
|
||||
if (fetchFromMermaidServer(source, svgPath))
|
||||
return QUrl::fromLocalFile(svgPath).toString();
|
||||
|
||||
QTemporaryFile input;
|
||||
input.setFileTemplate(QStringLiteral("/tmp/ollie-diagram-XXXXXX.mmd"));
|
||||
if (!input.open())
|
||||
return QString();
|
||||
input.write(source.toUtf8());
|
||||
input.close();
|
||||
|
||||
QString mmdc = QStandardPaths::findExecutable(QStringLiteral("mmdc"));
|
||||
if (mmdc.isEmpty())
|
||||
mmdc = QStandardPaths::findExecutable(QStringLiteral("mermaid"));
|
||||
if (mmdc.isEmpty())
|
||||
return QString();
|
||||
|
||||
QProcess proc;
|
||||
proc.setProcessChannelMode(QProcess::MergedChannels);
|
||||
proc.start(mmdc, {
|
||||
QStringLiteral("-i"), input.fileName(),
|
||||
QStringLiteral("-o"), svgPath,
|
||||
QStringLiteral("-b"), QStringLiteral("transparent")
|
||||
});
|
||||
|
||||
if (!proc.waitForFinished(15000))
|
||||
return QString();
|
||||
} else if (lang == QStringLiteral("drawio")) {
|
||||
// Draw.io — try local export server (localhost:8082), fall back to drawio CLI
|
||||
QNetworkAccessManager nam;
|
||||
QUrl url(QStringLiteral("http://localhost:8082/"));
|
||||
QNetworkRequest req(url);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
|
||||
QJsonDocument body(QJsonObject{
|
||||
{QStringLiteral("xml"), source},
|
||||
{QStringLiteral("format"), QStringLiteral("svg")}
|
||||
});
|
||||
|
||||
QScopedPointer<QNetworkReply> reply(nam.post(req, body.toJson(QJsonDocument::Compact)));
|
||||
|
||||
QEventLoop loop;
|
||||
QObject::connect(reply.data(), &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
timer.start(10000); // 10s — draw.io can be slow
|
||||
loop.exec();
|
||||
|
||||
bool serverOk = false;
|
||||
if (reply->isFinished() && reply->error() == QNetworkReply::NoError) {
|
||||
QByteArray svg = reply->readAll();
|
||||
if (!svg.isEmpty() && svg.contains("<svg")) {
|
||||
QFile out(svgPath);
|
||||
if (out.open(QIODevice::WriteOnly)) {
|
||||
out.write(svg);
|
||||
serverOk = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverOk) {
|
||||
// Fall back to drawio CLI
|
||||
QString drawio = QStandardPaths::findExecutable(QStringLiteral("drawio"));
|
||||
if (drawio.isEmpty())
|
||||
drawio = QStandardPaths::findExecutable(QStringLiteral("draw.io"));
|
||||
if (drawio.isEmpty())
|
||||
return QString();
|
||||
|
||||
QTemporaryFile input;
|
||||
input.setFileTemplate(QStringLiteral("/tmp/ollie-diagram-XXXXXX.drawio"));
|
||||
if (!input.open())
|
||||
return QString();
|
||||
input.write(source.toUtf8());
|
||||
input.close();
|
||||
|
||||
QProcess proc;
|
||||
proc.setProcessChannelMode(QProcess::MergedChannels);
|
||||
proc.start(drawio, {
|
||||
QStringLiteral("--export"),
|
||||
QStringLiteral("--format"), QStringLiteral("svg"),
|
||||
QStringLiteral("--output"), svgPath,
|
||||
input.fileName()
|
||||
});
|
||||
|
||||
if (!proc.waitForFinished(30000)) // draw.io CLI can be slow (Electron)
|
||||
return QString();
|
||||
}
|
||||
}
|
||||
|
||||
if (!QFileInfo::exists(svgPath))
|
||||
return QString();
|
||||
|
||||
return QUrl::fromLocalFile(svgPath).toString();
|
||||
}
|
||||
|
||||
bool ChatBlockModel::invalidateDiagram(const QString &source, const QString &language) const
|
||||
{
|
||||
if (source.isEmpty()) return false;
|
||||
|
||||
QString lang = language.toLower().trimmed();
|
||||
if (lang == QStringLiteral("mxgraph"))
|
||||
lang = QStringLiteral("drawio");
|
||||
|
||||
QByteArray hash = QCryptographicHash::hash(
|
||||
(lang + "\x1f" + source).toUtf8(), QCryptographicHash::Sha256).toHex().left(16);
|
||||
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
|
||||
+ QStringLiteral("/diagrams");
|
||||
QString svgPath = cacheDir + "/" + QString::fromLatin1(hash) + ".svg";
|
||||
|
||||
return QFile::remove(svgPath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,11 +31,12 @@ struct ContentBlock {
|
|||
enum Type {
|
||||
Prose, // regular markdown text
|
||||
Code, // fenced code block
|
||||
Diff // unified diff (detected by content pattern)
|
||||
Diff, // unified diff (detected by content pattern)
|
||||
Diagram // mermaid/plantuml rendered as image
|
||||
};
|
||||
|
||||
Type type;
|
||||
QString language; // for Code blocks
|
||||
QString language; // for Code and Diagram blocks
|
||||
QString content;
|
||||
};
|
||||
|
||||
|
|
@ -79,6 +80,8 @@ public:
|
|||
const QString &workingDirectory = QString()) const;
|
||||
Q_INVOKABLE bool loadEarlier(); // returns true if more blocks were loaded
|
||||
Q_INVOKABLE void setReactions(const QVariantMap &reactions);
|
||||
Q_INVOKABLE QString diagramUrl(const QString &source, const QString &language) const;
|
||||
Q_INVOKABLE bool invalidateDiagram(const QString &source, const QString &language) const;
|
||||
|
||||
signals:
|
||||
void rawTextChanged();
|
||||
|
|
|
|||
212
gui/main-kf5.qml
212
gui/main-kf5.qml
|
|
@ -1482,6 +1482,7 @@ ApplicationWindow {
|
|||
Layout.fillWidth: true
|
||||
property var subBlock: modelData
|
||||
sourceComponent: subBlock.type === "code" ? codeSubDelegate
|
||||
: subBlock.type === "diagram" ? diagramSubDelegate
|
||||
: subBlock.type === "diff" ? diffSubDelegate
|
||||
: subBlock.type === "interrupt" ? interruptSubDelegate
|
||||
: subBlock.type === "video" ? videoSubDelegate
|
||||
|
|
@ -1589,6 +1590,7 @@ ApplicationWindow {
|
|||
Layout.fillWidth: true
|
||||
property var subBlock: modelData
|
||||
sourceComponent: subBlock.type === "code" ? codeSubDelegate
|
||||
: subBlock.type === "diagram" ? diagramSubDelegate
|
||||
: subBlock.type === "diff" ? diffSubDelegate
|
||||
: subBlock.type === "interrupt" ? interruptSubDelegate
|
||||
: subBlock.type === "video" ? videoSubDelegate
|
||||
|
|
@ -1689,6 +1691,7 @@ ApplicationWindow {
|
|||
Layout.fillWidth: true
|
||||
property var subBlock: modelData
|
||||
sourceComponent: subBlock.type === "code" ? codeSubDelegate
|
||||
: subBlock.type === "diagram" ? diagramSubDelegate
|
||||
: subBlock.type === "diff" ? diffSubDelegate
|
||||
: subBlock.type === "interrupt" ? interruptSubDelegate
|
||||
: subBlock.type === "video" ? videoSubDelegate
|
||||
|
|
@ -1751,6 +1754,7 @@ ApplicationWindow {
|
|||
Layout.fillWidth: true
|
||||
property var subBlock: modelData
|
||||
sourceComponent: subBlock.type === "code" ? codeSubDelegate
|
||||
: subBlock.type === "diagram" ? diagramSubDelegate
|
||||
: subBlock.type === "diff" ? diffSubDelegate
|
||||
: subBlock.type === "interrupt" ? interruptSubDelegate
|
||||
: subBlock.type === "video" ? videoSubDelegate
|
||||
|
|
@ -2095,6 +2099,214 @@ ApplicationWindow {
|
|||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: diagramSubDelegate
|
||||
Rectangle {
|
||||
implicitHeight: diagramCol.implicitHeight
|
||||
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
|
||||
radius: 4
|
||||
border.width: 1
|
||||
border.color: palette.mid
|
||||
|
||||
ColumnLayout {
|
||||
id: diagramCol
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 0
|
||||
|
||||
// Rendered diagram image (white background for dark theme readability)
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 8
|
||||
Layout.maximumHeight: 600
|
||||
implicitHeight: diagramImg.implicitHeight > 0 ? Math.min(diagramImg.implicitHeight, 600) : 100
|
||||
color: "#ffffff"
|
||||
radius: 4
|
||||
|
||||
Image {
|
||||
id: diagramImg
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: chatModel.diagramUrl(subBlock.content || "", subBlock.language || "")
|
||||
asynchronous: true
|
||||
cache: false
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: diagramImg.status === Image.Loading
|
||||
visible: running
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: "\u26A0 Failed to load diagram"
|
||||
visible: diagramImg.status === Image.Error
|
||||
color: "#666"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function(mouse) {
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
chatModel.invalidateDiagram(subBlock.content || "", subBlock.language || "")
|
||||
diagramImg.source = ""
|
||||
diagramImg.source = chatModel.diagramUrl(subBlock.content || "", subBlock.language || "")
|
||||
} else {
|
||||
diagramFullscreen.diagramSource = diagramImg.source
|
||||
diagramFullscreen.visible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible source toggle
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 4
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
id: diagramToggle
|
||||
property bool expanded: false
|
||||
text: (expanded ? "\u25BC" : "\u25B6") + " " + (subBlock.language || "source")
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.8
|
||||
opacity: 0.6
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: diagramToggle.expanded = !diagramToggle.expanded
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
ToolButton {
|
||||
icon.name: "edit-copy"
|
||||
implicitWidth: 28
|
||||
implicitHeight: 28
|
||||
onClicked: chatModel.copyToClipboard(subBlock.content)
|
||||
ToolTip.text: "Copy source"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsed source code
|
||||
TextEdit {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 8
|
||||
visible: diagramToggle.expanded
|
||||
text: subBlock.content || ""
|
||||
wrapMode: TextEdit.Wrap
|
||||
textFormat: TextEdit.PlainText
|
||||
font.family: "monospace"
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
||||
color: palette.text
|
||||
selectionColor: palette.highlight
|
||||
selectedTextColor: palette.highlightedText
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen diagram viewer (pannable + zoomable)
|
||||
Popup {
|
||||
id: diagramFullscreen
|
||||
property string diagramSource: ""
|
||||
parent: Overlay.overlay
|
||||
anchors.centerIn: parent
|
||||
width: parent ? parent.width * 0.95 : 800
|
||||
height: parent ? parent.height * 0.95 : 600
|
||||
modal: true
|
||||
dim: true
|
||||
padding: 0
|
||||
|
||||
background: Rectangle {
|
||||
color: "#ffffff"
|
||||
radius: 8
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Escape"
|
||||
enabled: diagramFullscreen.visible
|
||||
onActivated: diagramFullscreen.close()
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
z: 10
|
||||
icon.name: "window-close"
|
||||
onClicked: diagramFullscreen.close()
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.margins: 8
|
||||
z: 10
|
||||
text: Math.round(diagramZoomImg.zoomFactor * 100) + "%"
|
||||
font.pixelSize: Qt.application.font.pixelSize * 0.8
|
||||
onClicked: {
|
||||
diagramZoomImg.zoomFactor = 1.0
|
||||
diagramFlick.contentX = 0
|
||||
diagramFlick.contentY = 0
|
||||
}
|
||||
ToolTip.text: "Reset zoom"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: diagramFlick
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
clip: true
|
||||
contentWidth: diagramZoomImg.width
|
||||
contentHeight: diagramZoomImg.height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Image {
|
||||
id: diagramZoomImg
|
||||
source: diagramFullscreen.diagramSource
|
||||
fillMode: Image.PreserveAspectFit
|
||||
cache: false
|
||||
|
||||
property real zoomFactor: 1.0
|
||||
width: Math.max(diagramFlick.width, diagramFlick.width * zoomFactor)
|
||||
height: Math.max(diagramFlick.height, diagramFlick.height * zoomFactor)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: function(mouse) {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
diagramFullscreen.close()
|
||||
}
|
||||
onDoubleClicked: diagramFullscreen.close()
|
||||
onWheel: function(wheel) {
|
||||
var factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87
|
||||
var newZoom = Math.max(0.25, Math.min(10.0, diagramZoomImg.zoomFactor * factor))
|
||||
diagramZoomImg.zoomFactor = newZoom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAboutToShow: {
|
||||
diagramZoomImg.zoomFactor = 1.0
|
||||
diagramFlick.contentX = 0
|
||||
diagramFlick.contentY = 0
|
||||
}
|
||||
}
|
||||
|
||||
// --- Process Manager Window ---
|
||||
Window {
|
||||
id: processManagerWindow
|
||||
|
|
|
|||
Reference in New Issue