1060 lines
37 KiB
C++
1060 lines
37 KiB
C++
#include "chatblockmodel.h"
|
|
|
|
#include <QRegularExpression>
|
|
#include <KSyntaxHighlighting/Repository>
|
|
#include <KSyntaxHighlighting/Definition>
|
|
#include <KSyntaxHighlighting/Theme>
|
|
#include <KSyntaxHighlighting/AbstractHighlighter>
|
|
#include <KSyntaxHighlighting/State>
|
|
#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>
|
|
#include <QProcess>
|
|
#include <QStandardPaths>
|
|
|
|
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("<pre style=\"margin:0;white-space:pre-wrap;\">")
|
|
+ content.toHtmlEscaped() + QStringLiteral("</pre>");
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QHash<int, QByteArray> 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<QString, QString> 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<ChatBlock> 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<ChatBlock> 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<ChatBlock> ChatBlockModel::parseBlocks(const QString &text) const
|
|
{
|
|
QList<ChatBlock> 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<QPair<int, QRegularExpressionMatch>> 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 <system-user-interruption> tags, then parse each segment
|
|
static const QRegularExpression interruptRe(
|
|
R"(<system-user-interruption>\n?(.*?)\n?</system-user-interruption>)",
|
|
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<QPair<int, QRegularExpressionMatch>> 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("<span style=\"color:") + fg.name() + QStringLiteral(";\">")
|
|
+ text + QStringLiteral("</span>");
|
|
} 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("<pre style=\"margin:0;white-space:pre-wrap;\">")
|
|
+ code.toHtmlEscaped() + QStringLiteral("</pre>");
|
|
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("<pre style=\"margin:0;white-space:pre-wrap;\">");
|
|
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("</pre>");
|
|
|
|
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"((?<![A-Za-z0-9_:])(`?)(/[^\s`<>\[\]\(\),;!?\:]+)(?::(\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<QPair<int, int>> 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<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("-t"), QStringLiteral("default"),
|
|
QStringLiteral("-b"), QStringLiteral("white")
|
|
});
|
|
|
|
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);
|
|
}
|