This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/gui/chatblockmodel.cpp

626 lines
21 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 <QDesktopServices>
#include <QDir>
#include <QUrl>
#include <QJsonDocument>
#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;
return m_blocks.size();
}
QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_blocks.size())
return {};
const ChatBlock &block = m_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 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"},
};
}
void ChatBlockModel::setRawText(const QString &text)
{
if (text == m_rawText) return;
m_rawText = text;
reparse();
emit rawTextChanged();
}
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) {
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], [reasoning], [call:name], [tool:name], [error], [info] msg
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;
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";
}
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);
}
return result;
}
// 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;
}