#include "chatblockmodel.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include static KSyntaxHighlighting::Repository *s_repo = nullptr; static KSyntaxHighlighting::Repository &repo() { if (!s_repo) s_repo = new KSyntaxHighlighting::Repository(); return *s_repo; } ChatBlockModel::ChatBlockModel(QObject *parent) : QAbstractListModel(parent) { } int ChatBlockModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) return 0; const auto &blocks = m_filterText.isEmpty() ? m_blocks : m_filteredBlocks; return blocks.size(); } QVariant ChatBlockModel::data(const QModelIndex &index, int role) const { const auto &blocks = m_filterText.isEmpty() ? m_blocks : m_filteredBlocks; if (!index.isValid() || index.row() >= blocks.size()) return {}; const ChatBlock &block = blocks[index.row()]; switch (role) { case BlockTypeRole: { switch (block.type) { case ChatBlock::User: return QStringLiteral("user"); case ChatBlock::Assistant: return QStringLiteral("assistant"); case ChatBlock::Reasoning: return QStringLiteral("reasoning"); case ChatBlock::Call: return QStringLiteral("call"); case ChatBlock::Tool: return QStringLiteral("tool"); case ChatBlock::Error: return QStringLiteral("error"); case ChatBlock::Info: return QStringLiteral("info"); } return {}; } case NameRole: return block.name; case ContentRole: return block.content; case ResponseIdRole: return block.responseId; case ReactionRole: return block.reaction; case SubBlocksRole: if (block.cachedContent != block.content) { block.cachedSubBlocks = parseSubBlocks(block.content); block.cachedContent = block.content; } return block.cachedSubBlocks; case FormattedContentRole: { // For call blocks: always pretty-print JSON // For tool blocks: detect JSON/diff, format accordingly // For others: just escape HTML QString content = block.content; if (content.isEmpty()) return QString(); if (block.type == ChatBlock::Call) { return prettyJson(content); } QString trimmed = content.trimmed(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { return prettyJson(content); } if (trimmed.startsWith(QLatin1String("---")) || trimmed.startsWith(QLatin1String("diff "))) { return highlightedHtml(content, QStringLiteral("Diff")); } // Plain text: preserve whitespace return QStringLiteral("
")
            + content.toHtmlEscaped() + QStringLiteral("
"); } } return {}; } QHash ChatBlockModel::roleNames() const { return { {BlockTypeRole, "blockType"}, {NameRole, "blockName"}, {ContentRole, "blockContent"}, {SubBlocksRole, "subBlocks"}, {FormattedContentRole, "formattedContent"}, {ResponseIdRole, "responseId"}, {ReactionRole, "reaction"}, }; } void ChatBlockModel::setReactions(const QVariantMap &reactions) { QHash reactionMap; for (auto it = reactions.constBegin(); it != reactions.constEnd(); ++it) reactionMap[it.key()] = it.value().toString(); // Update m_allBlocks for future scroll/loadEarlier for (int i = 0; i < m_allBlocks.size(); ++i) { if (m_allBlocks[i].type == ChatBlock::Assistant && !m_allBlocks[i].responseId.isEmpty()) m_allBlocks[i].reaction = reactionMap.value(m_allBlocks[i].responseId); } // Update visible m_blocks with dataChanged signal for (int i = 0; i < m_blocks.size(); ++i) { if (m_blocks[i].type == ChatBlock::Assistant && !m_blocks[i].responseId.isEmpty()) { QString emoji = reactionMap.value(m_blocks[i].responseId); if (m_blocks[i].reaction != emoji) { m_blocks[i].reaction = emoji; emit dataChanged(index(i), index(i)); } } } // Update filtered blocks too for (int i = 0; i < m_filteredBlocks.size(); ++i) { if (m_filteredBlocks[i].type == ChatBlock::Assistant && !m_filteredBlocks[i].responseId.isEmpty()) m_filteredBlocks[i].reaction = reactionMap.value(m_filteredBlocks[i].responseId); } } void ChatBlockModel::setRawText(const QString &text) { if (text == m_rawText) return; m_rawText = text; reparse(); if (!m_filterText.isEmpty()) applyFilter(); emit rawTextChanged(); } void ChatBlockModel::setFilterText(const QString &text) { if (text == m_filterText) return; m_filterText = text; applyFilter(); emit filterTextChanged(); } void ChatBlockModel::applyFilter() { beginResetModel(); if (m_filterText.isEmpty()) { m_filteredBlocks.clear(); } else { m_filteredBlocks.clear(); for (const auto &block : m_allBlocks) { if (block.content.contains(m_filterText, Qt::CaseInsensitive) || block.name.contains(m_filterText, Qt::CaseInsensitive)) { m_filteredBlocks.append(block); } } } endResetModel(); } static constexpr int INITIAL_BLOCKS = 20; static constexpr int LOAD_MORE_COUNT = 30; void ChatBlockModel::reparse() { m_allBlocks = parseBlocks(m_rawText); updateVisibleWindow(); } void ChatBlockModel::updateVisibleWindow() { // Determine visible window: last INITIAL_BLOCKS (or expand if already showing more) int desiredStart; if (m_blocks.isEmpty()) { // Initial load: show last N blocks desiredStart = qMax(0, m_allBlocks.size() - INITIAL_BLOCKS); } else { // Streaming update: keep the same start offset, just extend the tail desiredStart = m_visibleStart; // But if allBlocks shrank (session switch), reset if (desiredStart >= m_allBlocks.size()) desiredStart = qMax(0, m_allBlocks.size() - INITIAL_BLOCKS); } m_visibleStart = desiredStart; QList newBlocks = m_allBlocks.mid(m_visibleStart); // Incremental update: only change what differs int commonCount = qMin(m_blocks.size(), newBlocks.size()); int firstDiff = commonCount; for (int i = 0; i < commonCount; ++i) { if (m_blocks[i].content != newBlocks[i].content || m_blocks[i].type != newBlocks[i].type || m_blocks[i].name != newBlocks[i].name || m_blocks[i].responseId != newBlocks[i].responseId || m_blocks[i].reaction != newBlocks[i].reaction) { firstDiff = i; break; } } // Remove extra old blocks if (m_blocks.size() > newBlocks.size()) { beginRemoveRows(QModelIndex(), newBlocks.size(), m_blocks.size() - 1); m_blocks = m_blocks.mid(0, newBlocks.size()); endRemoveRows(); } // Update changed existing blocks for (int i = firstDiff; i < qMin(m_blocks.size(), newBlocks.size()); ++i) { m_blocks[i] = newBlocks[i]; emit dataChanged(index(i), index(i)); } // Append new blocks at end if (newBlocks.size() > m_blocks.size()) { beginInsertRows(QModelIndex(), m_blocks.size(), newBlocks.size() - 1); for (int i = m_blocks.size(); i < newBlocks.size(); ++i) { m_blocks.append(newBlocks[i]); } endInsertRows(); } } bool ChatBlockModel::loadEarlier() { if (m_visibleStart <= 0) return false; // already showing everything int loadCount = qMin(LOAD_MORE_COUNT, m_visibleStart); int newStart = m_visibleStart - loadCount; QList prepend = m_allBlocks.mid(newStart, loadCount); beginInsertRows(QModelIndex(), 0, loadCount - 1); for (int i = loadCount - 1; i >= 0; --i) { m_blocks.prepend(prepend[i]); } m_visibleStart = newStart; endInsertRows(); return true; } QList ChatBlockModel::parseBlocks(const QString &text) const { QList blocks; if (text.isEmpty()) return blocks; // Block headers: [user], [assistant:id], [reasoning], [call:name], etc. static const QRegularExpression headerRe( R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\](.*)$)", QRegularExpression::MultilineOption); auto it = headerRe.globalMatch(text); QList> headers; while (it.hasNext()) { auto match = it.next(); headers.append({match.capturedStart(), match}); } if (headers.isEmpty()) { // No headers found — treat entire text as assistant block ChatBlock b; b.type = ChatBlock::Assistant; b.content = text; blocks.append(b); return blocks; } for (int i = 0; i < headers.size(); ++i) { const auto &[pos, match] = headers[i]; QString typeStr = match.captured(1); QString name = match.captured(2); QString inlineContent = match.captured(3); // for [info] msg style int contentStart = match.capturedEnd(); int contentEnd = (i + 1 < headers.size()) ? headers[i + 1].first : text.size(); QString content; if (typeStr == "info" && !inlineContent.trimmed().isEmpty()) { // [info] has inline content on same line content = inlineContent.trimmed(); } else { content = text.mid(contentStart, contentEnd - contentStart); // Strip leading newline and trailing double-newline if (content.startsWith('\n')) content = content.mid(1); while (content.endsWith("\n\n")) content.chop(1); } ChatBlock b; if (typeStr == "user") b.type = ChatBlock::User; else if (typeStr == "assistant") b.type = ChatBlock::Assistant; else if (typeStr == "reasoning") b.type = ChatBlock::Reasoning; else if (typeStr == "call") b.type = ChatBlock::Call; else if (typeStr == "tool") b.type = ChatBlock::Tool; else if (typeStr == "error") b.type = ChatBlock::Error; else if (typeStr == "info") b.type = ChatBlock::Info; if (b.type == ChatBlock::Assistant) { b.responseId = name; } else { b.name = name; } b.content = content; blocks.append(b); } return blocks; } QVariantList ChatBlockModel::parseSubBlocks(const QString &content) const { QVariantList result; if (content.isEmpty()) return result; // First pass: split on tags, then parse each segment static const QRegularExpression interruptRe( R"(\n?(.*?)\n?)", QRegularExpression::DotMatchesEverythingOption); // Split on code fences: ```language\n...\n``` static const QRegularExpression fenceRe( R"(^```(\w*)\s*\n((?:(?!^```).)*?)\n^```\s*$)", QRegularExpression::MultilineOption | QRegularExpression::DotMatchesEverythingOption); // Combined regex to find both interrupts and code fences in order // Process interrupts first, then code fences within non-interrupt segments int lastEnd = 0; auto intIt = interruptRe.globalMatch(content); QList> interrupts; while (intIt.hasNext()) { auto match = intIt.next(); interrupts.append({match.capturedStart(), match}); } auto parseSegment = [&](const QString &segment) { if (segment.trimmed().isEmpty()) return; int segLastEnd = 0; auto it = fenceRe.globalMatch(segment); while (it.hasNext()) { auto match = it.next(); if (match.capturedStart() > segLastEnd) { QString prose = segment.mid(segLastEnd, match.capturedStart() - segLastEnd).trimmed(); if (!prose.isEmpty()) { QVariantMap m; m["type"] = "prose"; m["content"] = prose; result.append(m); } } QString lang = match.captured(1); QString code = match.captured(2); QString type = "code"; 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; m["type"] = type; m["language"] = lang; m["content"] = code; result.append(m); segLastEnd = match.capturedEnd(); } if (segLastEnd < segment.size()) { QString prose = segment.mid(segLastEnd).trimmed(); if (!prose.isEmpty()) { QVariantMap m; m["type"] = "prose"; m["content"] = prose; result.append(m); } } }; if (interrupts.isEmpty()) { // No interrupts — parse entire content for code fences parseSegment(content); } else { for (int i = 0; i < interrupts.size(); ++i) { const auto &[pos, match] = interrupts[i]; // Parse segment before this interrupt if (pos > lastEnd) { parseSegment(content.mid(lastEnd, pos - lastEnd)); } // Add the interrupt as its own sub-block QVariantMap m; m["type"] = "interrupt"; m["content"] = match.captured(1).trimmed(); result.append(m); lastEnd = match.capturedEnd(); } // Parse trailing segment after last interrupt if (lastEnd < content.size()) { parseSegment(content.mid(lastEnd)); } } // If nothing found at all, the entire content is prose if (result.isEmpty()) { QVariantMap m; m["type"] = "prose"; m["content"] = content; result.append(m); } // Post-pass: extract video URLs from prose blocks into video sub-blocks // Matches: YouTube, Vimeo, Dailymotion, Twitch clips, PeerTube, and direct media files static const QRegularExpression videoUrlRe( R"((https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|vimeo\.com/|dailymotion\.com/video/|dai\.ly/|twitch\.tv/\w+/clip/|clips\.twitch\.tv/)[^\s)\]]+|https?://[^\s)\]]+\.(?:mp4|webm|ogv|m3u8)))"); QVariantList final; for (const QVariant &v : result) { QVariantMap block = v.toMap(); if (block["type"].toString() != "prose") { final.append(block); continue; } QString prose = block["content"].toString(); auto vit = videoUrlRe.globalMatch(prose); int lastPos = 0; bool hasVideo = false; while (vit.hasNext()) { hasVideo = true; auto vmatch = vit.next(); if (vmatch.capturedStart() > lastPos) { QString before = prose.mid(lastPos, vmatch.capturedStart() - lastPos).trimmed(); if (!before.isEmpty()) { QVariantMap pm; pm["type"] = "prose"; pm["content"] = before; final.append(pm); } } QVariantMap vm; vm["type"] = "video"; vm["content"] = vmatch.captured(1); final.append(vm); lastPos = vmatch.capturedEnd(); } if (hasVideo) { if (lastPos < prose.size()) { QString after = prose.mid(lastPos).trimmed(); if (!after.isEmpty()) { QVariantMap pm; pm["type"] = "prose"; pm["content"] = after; final.append(pm); } } } else { final.append(block); } } return final; } // Custom highlighter that builds HTML string directly class HtmlBuilder : public KSyntaxHighlighting::AbstractHighlighter { public: QString html; using AbstractHighlighter::AbstractHighlighter; KSyntaxHighlighting::State processLine(const QString &line, const KSyntaxHighlighting::State &state) { m_currentLine = line; return highlightLine(line, state); } protected: void applyFormat(int offset, int length, const KSyntaxHighlighting::Format &format) override { QString text = m_currentLine.mid(offset, length).toHtmlEscaped(); if (format.hasTextColor(theme())) { QColor fg = format.textColor(theme()); html += QStringLiteral("") + text + QStringLiteral(""); } else { html += text; } } private: QString m_currentLine; }; QString ChatBlockModel::highlightedHtml(const QString &code, const QString &language) const { // Check cache first QString cacheKey = language + "\x1f" + code; auto cached = m_highlightCache.find(cacheKey); if (cached != m_highlightCache.end()) return *cached; auto def = repo().definitionForName(language); if (!def.isValid()) { def = repo().definitionForFileName("file." + language); } if (!def.isValid()) { QString plain = QStringLiteral("
")
            + code.toHtmlEscaped() + QStringLiteral("
"); m_highlightCache.insert(cacheKey, plain); return plain; } bool dark = QGuiApplication::palette().color(QPalette::Window).lightnessF() < 0.5; auto theme = dark ? repo().defaultTheme(KSyntaxHighlighting::Repository::DarkTheme) : repo().defaultTheme(KSyntaxHighlighting::Repository::LightTheme); HtmlBuilder builder; builder.setDefinition(def); builder.setTheme(theme); KSyntaxHighlighting::State state; const QStringList lines = code.split('\n'); QString result = QStringLiteral("
");
    for (int i = 0; i < lines.size(); ++i) {
        state = builder.processLine(lines[i], state);
        if (i < lines.size() - 1)
            builder.html += '\n';
    }
    result += builder.html + QStringLiteral("
"); m_highlightCache.insert(cacheKey, result); return result; } QString ChatBlockModel::prettyJson(const QString &text) const { // Try to parse and pretty-print JSON, then syntax highlight QJsonParseError err; QJsonDocument doc = QJsonDocument::fromJson(text.toUtf8(), &err); QString formatted; if (err.error == QJsonParseError::NoError) { formatted = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); } else { formatted = text; } return highlightedHtml(formatted, QStringLiteral("JSON")); } void ChatBlockModel::copyToClipboard(const QString &text) const { QGuiApplication::clipboard()->setText(text); } QString ChatBlockModel::linkifyFilePaths(const QString &markdown) const { // Agent output normally uses absolute paths, optionally followed by :line:column. // Restrict matching to absolute paths so ordinary prose and relative URLs remain intact. static const QRegularExpression pathRe( R"((?\[\]\(\),;!?\:]+)(?::(\d+))?(?::(\d+))?\1)"); // Preserve complete Markdown links; rewriting a path inside their label would // produce nested links that Qt renders as literal Markdown. static const QRegularExpression markdownLinkRe(R"(\[[^\]]*\]\([^\)]*\))"); QList> linkRanges; auto links = markdownLinkRe.globalMatch(markdown); while (links.hasNext()) { const QRegularExpressionMatch link = links.next(); linkRanges.append({link.capturedStart(), link.capturedEnd()}); } QString result; result.reserve(markdown.size() + 64); int lastEnd = 0; auto matches = pathRe.globalMatch(markdown); while (matches.hasNext()) { const QRegularExpressionMatch match = matches.next(); const QString path = match.captured(2); const int start = match.capturedStart(); bool insideMarkdownLink = false; for (const auto &range : linkRanges) { if (start >= range.first && start < range.second) { insideMarkdownLink = true; break; } } if (insideMarkdownLink) continue; result += markdown.mid(lastEnd, start - lastEnd); QUrl url = QUrl::fromLocalFile(path); QStringList query; if (!match.captured(3).isEmpty()) query << QStringLiteral("line=") + match.captured(3); if (!match.captured(4).isEmpty()) query << QStringLiteral("column=") + match.captured(4); if (!query.isEmpty()) url.setQuery(query.join(QLatin1Char('&'))); QString label = match.captured(0); if (!match.captured(1).isEmpty()) label = label.mid(1, label.size() - 2); result += QStringLiteral("[") + label + QStringLiteral("](") + url.toString(QUrl::FullyEncoded) + QStringLiteral(")"); lastEnd = match.capturedEnd(); } result += markdown.mid(lastEnd); return result; } void ChatBlockModel::openLink(const QString &link) const { const QUrl url(link); if (!url.isLocalFile()) { QDesktopServices::openUrl(url); return; } const QString path = url.toLocalFile(); int line = 0; int column = 0; const QStringList queryItems = url.query().split(QLatin1Char('&'), Qt::SkipEmptyParts); for (const QString &item : queryItems) { if (item.startsWith(QLatin1String("line="))) line = item.mid(5).toInt(); else if (item.startsWith(QLatin1String("column="))) column = item.mid(7).toInt(); } const QString kate = QStandardPaths::findExecutable(QStringLiteral("kate")); if (!kate.isEmpty() && line > 0) { QStringList args; args << QStringLiteral("--line") << QString::number(line); if (column > 0) args << QStringLiteral("--column") << QString::number(column); args << path; if (QProcess::startDetached(kate, args)) return; } QDesktopServices::openUrl(QUrl::fromLocalFile(path)); } bool ChatBlockModel::executeInTerminal(const QString &code, const QString &language, const QString &workingDirectory) const { // Determine interpreter/shebang QString shebang = QStringLiteral("#!/bin/bash"); QString ext = QStringLiteral(".sh"); QString lang = language.toLower().trimmed(); if (lang == "python" || lang == "python3" || lang == "py") { shebang = QStringLiteral("#!/usr/bin/env python3"); ext = QStringLiteral(".py"); } else if (lang == "perl") { shebang = QStringLiteral("#!/usr/bin/env perl"); ext = QStringLiteral(".pl"); } else if (lang == "ruby" || lang == "rb") { shebang = QStringLiteral("#!/usr/bin/env ruby"); ext = QStringLiteral(".rb"); } else if (lang == "lua") { shebang = QStringLiteral("#!/usr/bin/env lua"); ext = QStringLiteral(".lua"); } else if (lang == "node" || lang == "javascript" || lang == "js") { shebang = QStringLiteral("#!/usr/bin/env node"); ext = QStringLiteral(".js"); } else if (lang == "php") { shebang = QStringLiteral("#!/usr/bin/env php"); ext = QStringLiteral(".php"); } else if (lang == "zsh") { shebang = QStringLiteral("#!/usr/bin/env zsh"); ext = QStringLiteral(".zsh"); } else if (lang == "fish") { shebang = QStringLiteral("#!/usr/bin/env fish"); ext = QStringLiteral(".fish"); } // Write temp script QTemporaryFile tmp; tmp.setFileTemplate(QStringLiteral("/tmp/ollie-exec-XXXXXX") + ext); tmp.setAutoRemove(false); // keep it so terminal can read it if (!tmp.open()) return false; QString script = shebang + QStringLiteral("\n") + code + QStringLiteral("\n"); tmp.write(script.toUtf8()); tmp.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); QString scriptPath = tmp.fileName(); tmp.close(); // Launch in terminal — try konsole, then x-terminal-emulator, then xterm QStringList terminals = { QStringLiteral("konsole"), QStringLiteral("x-terminal-emulator"), QStringLiteral("xterm") }; for (const QString &term : terminals) { QString exe = QStandardPaths::findExecutable(term); if (exe.isEmpty()) continue; QStringList args; if (term == "konsole") { args << QStringLiteral("--hold") << QStringLiteral("-e") << scriptPath; } else if (term == "xterm") { args << QStringLiteral("-hold") << QStringLiteral("-e") << scriptPath; } else { args << QStringLiteral("-e") << scriptPath; } const QString cwd = QDir(workingDirectory).exists() ? workingDirectory : QDir::homePath(); if (QProcess::startDetached(exe, args, cwd)) return true; } 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(data[i]); int b1 = (i + 1 < data.size()) ? static_cast(data[i + 1]) : 0; int b2 = (i + 2 < data.size()) ? static_cast(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 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(" 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(" 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("