374 lines
13 KiB
C++
374 lines
13 KiB
C++
#include "chatblockmodel.h"
|
|
|
|
#include <QApplication>
|
|
#include <QPalette>
|
|
#include <QRegularExpressionMatch>
|
|
#include <KSyntaxHighlighting/Repository>
|
|
#include <KSyntaxHighlighting/Definition>
|
|
#include <KSyntaxHighlighting/Theme>
|
|
#include <KSyntaxHighlighting/AbstractHighlighter>
|
|
#include <KSyntaxHighlighting/Format>
|
|
#include <KSyntaxHighlighting/State>
|
|
|
|
// --- Syntax Highlighting (copied from Kate plugin ChatRenderer) ---
|
|
|
|
class HtmlHighlighter : 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;
|
|
};
|
|
|
|
// --- Static helpers ---
|
|
|
|
static ChatBlock::Type blockTypeFromString(const QString &typeStr)
|
|
{
|
|
if (typeStr == "user") return ChatBlock::User;
|
|
if (typeStr == "assistant") return ChatBlock::Assistant;
|
|
if (typeStr == "reasoning") return ChatBlock::Reasoning;
|
|
if (typeStr == "call") return ChatBlock::Call;
|
|
if (typeStr == "tool") return ChatBlock::Tool;
|
|
if (typeStr == "error") return ChatBlock::Error;
|
|
if (typeStr == "info") return ChatBlock::Info;
|
|
if (typeStr == "retry") return ChatBlock::Retry;
|
|
return ChatBlock::Default;
|
|
}
|
|
|
|
// Fence opening pattern reused in multiple states
|
|
static const QRegularExpression s_fenceOpenRe(R"(^```(\w*)[ \t]*$)");
|
|
|
|
// --- Block Parsing ---
|
|
|
|
const QRegularExpression ChatBlockModel::s_headerRe(
|
|
R"(^\[\[\[(user|assistant|reasoning|call|tool|error|info|retry)(?::([^\]]*))?\]\]\]$)");
|
|
|
|
const QRegularExpression ChatBlockModel::s_endRe(
|
|
R"(^\[\[\[end\]\]\]$)");
|
|
|
|
const QRegularExpression ChatBlockModel::s_interruptOpenRe(
|
|
R"(^<system-user-interruption>\s*$)");
|
|
|
|
const QRegularExpression ChatBlockModel::s_interruptCloseRe(
|
|
R"(^</system-user-interruption>\s*$)");
|
|
|
|
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 &b = m_blocks[index.row()];
|
|
switch (role) {
|
|
case BlockTypeRole:
|
|
switch (b.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");
|
|
case ChatBlock::Retry: return QStringLiteral("retry");
|
|
case ChatBlock::Interrupt: return QStringLiteral("interrupt");
|
|
case ChatBlock::Default: return QStringLiteral("default");
|
|
}
|
|
return {};
|
|
case BlockNameRole:
|
|
return b.name;
|
|
case ContentRole:
|
|
return b.content;
|
|
case RenderedHtmlRole:
|
|
return b.renderedHtml;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QHash<int, QByteArray> ChatBlockModel::roleNames() const
|
|
{
|
|
return {
|
|
{BlockTypeRole, "blockType"},
|
|
{BlockNameRole, "blockName"},
|
|
{ContentRole, "blockContent"},
|
|
{RenderedHtmlRole, "blockRenderedHtml"},
|
|
};
|
|
}
|
|
|
|
void ChatBlockModel::appendData(const QString &text)
|
|
{
|
|
m_lineBuffer += text;
|
|
|
|
int idx;
|
|
while ((idx = m_lineBuffer.indexOf('\n')) >= 0) {
|
|
QString line = m_lineBuffer.left(idx);
|
|
m_lineBuffer = m_lineBuffer.mid(idx + 1);
|
|
processLine(line);
|
|
}
|
|
}
|
|
|
|
void ChatBlockModel::reset()
|
|
{
|
|
beginResetModel();
|
|
m_blocks.clear();
|
|
m_lineBuffer.clear();
|
|
m_state = State::Outside;
|
|
m_wasInBlock = false;
|
|
m_fenceLang.clear();
|
|
m_fenceBuffer.clear();
|
|
endResetModel();
|
|
emit countChanged();
|
|
}
|
|
|
|
void ChatBlockModel::trimFront(int keepCount)
|
|
{
|
|
if (m_blocks.size() <= keepCount)
|
|
return;
|
|
int removeCount = m_blocks.size() - keepCount;
|
|
beginRemoveRows(QModelIndex(), 0, removeCount - 1);
|
|
m_blocks.remove(0, removeCount);
|
|
endRemoveRows();
|
|
emit countChanged();
|
|
}
|
|
|
|
QString ChatBlockModel::escapeHtml(const QString &text) const
|
|
{
|
|
return text.toHtmlEscaped();
|
|
}
|
|
|
|
QString ChatBlockModel::highlightCode(const QString &code, const QString &language) const
|
|
{
|
|
KSyntaxHighlighting::Repository *repo = new KSyntaxHighlighting::Repository();
|
|
KSyntaxHighlighting::Definition def = repo->definitionForName(language);
|
|
if (!def.isValid())
|
|
def = repo->definitionForFileName("file." + language);
|
|
|
|
bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
|
|
KSyntaxHighlighting::Theme theme = repo->defaultTheme(dark
|
|
? KSyntaxHighlighting::Repository::DarkTheme
|
|
: KSyntaxHighlighting::Repository::LightTheme);
|
|
|
|
if (!def.isValid()) {
|
|
delete repo;
|
|
return QStringLiteral("<pre style=\"white-space:pre-wrap;\">") + escapeHtml(code) + QStringLiteral("</pre>");
|
|
}
|
|
|
|
HtmlHighlighter builder;
|
|
builder.setDefinition(def);
|
|
builder.setTheme(theme);
|
|
|
|
KSyntaxHighlighting::State state;
|
|
const QStringList lines = code.split('\n');
|
|
for (int i = 0; i < lines.size(); ++i) {
|
|
state = builder.processLine(lines[i], state);
|
|
if (i < lines.size() - 1)
|
|
builder.html += '\n';
|
|
}
|
|
|
|
delete repo;
|
|
return QStringLiteral("<pre style=\"white-space:pre-wrap;\">") + builder.html + QStringLiteral("</pre>");
|
|
}
|
|
|
|
QString ChatBlockModel::renderDiff(const QString &diff) const
|
|
{
|
|
QString html = QStringLiteral("<pre style=\"white-space:pre-wrap;\">");
|
|
const QStringList lines = diff.split('\n');
|
|
for (const QString &line : lines) {
|
|
if (line.startsWith('+')) {
|
|
html += QStringLiteral("<span style=\"color:#4caf50;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
|
|
} else if (line.startsWith('-')) {
|
|
html += QStringLiteral("<span style=\"color:#f44336;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
|
|
} else if (line.startsWith("@@")) {
|
|
html += QStringLiteral("<span style=\"color:#2196f3;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
|
|
} else {
|
|
html += escapeHtml(line) + '\n';
|
|
}
|
|
}
|
|
html += QStringLiteral("</pre>");
|
|
return html;
|
|
}
|
|
|
|
void ChatBlockModel::appendContent(const QString &line)
|
|
{
|
|
ChatBlock &last = m_blocks.last();
|
|
if (!last.content.isEmpty())
|
|
last.content += '\n';
|
|
last.content += line;
|
|
// Keep renderedHtml in sync if a fence was rendered earlier
|
|
// in this block, so post-fence content isn't invisible.
|
|
if (!last.renderedHtml.isEmpty()) {
|
|
if (!last.renderedHtml.endsWith('\n'))
|
|
last.renderedHtml += '\n';
|
|
last.renderedHtml += line.toHtmlEscaped();
|
|
}
|
|
QModelIndex idx = index(m_blocks.size() - 1);
|
|
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole});
|
|
}
|
|
|
|
void ChatBlockModel::processLine(const QString &line)
|
|
{
|
|
switch (m_state) {
|
|
|
|
// ────── State: InFence ──────────────────────────────────────────
|
|
case State::InFence: {
|
|
// Closing fence?
|
|
if (line.trimmed() == "```") {
|
|
QString rendered;
|
|
bool isDiff = (m_fenceLang == "diff") ||
|
|
(m_fenceLang.isEmpty() &&
|
|
(m_fenceBuffer.startsWith("--- ") ||
|
|
m_fenceBuffer.startsWith("diff --git")));
|
|
if (isDiff)
|
|
rendered = renderDiff(m_fenceBuffer);
|
|
else
|
|
rendered = highlightCode(m_fenceBuffer, m_fenceLang);
|
|
|
|
if (!m_blocks.isEmpty()) {
|
|
ChatBlock &last = m_blocks.last();
|
|
// Prepend any content that came before the fence so it
|
|
// isn't lost — the view uses renderedHtml exclusively
|
|
// when non-empty.
|
|
if (!last.content.isEmpty())
|
|
last.renderedHtml = escapeHtml(last.content) + '\n' + rendered;
|
|
else
|
|
last.renderedHtml = rendered;
|
|
QModelIndex idx = index(m_blocks.size() - 1);
|
|
emit dataChanged(idx, idx, {RenderedHtmlRole});
|
|
}
|
|
|
|
m_fenceLang.clear();
|
|
m_fenceBuffer.clear();
|
|
// Return to the context we were in before the fence opened
|
|
m_state = m_wasInBlock ? State::InBlock : State::Outside;
|
|
return;
|
|
}
|
|
|
|
// Content line — accumulate fence buffer
|
|
if (!m_fenceBuffer.isEmpty())
|
|
m_fenceBuffer += '\n';
|
|
m_fenceBuffer += line;
|
|
return;
|
|
}
|
|
|
|
// ────── State: Outside ──────────────────────────────────────────
|
|
case State::Outside: {
|
|
// Stray end markers are no-ops (partial stream connections)
|
|
if (s_endRe.match(line).hasMatch()) return;
|
|
if (s_interruptCloseRe.match(line).hasMatch()) return;
|
|
|
|
// Opening ``` outside a block
|
|
QRegularExpressionMatch fenceMatch = s_fenceOpenRe.match(line);
|
|
if (fenceMatch.hasMatch()) {
|
|
m_fenceLang = fenceMatch.captured(1);
|
|
m_fenceBuffer.clear();
|
|
m_wasInBlock = false;
|
|
m_state = State::InFence;
|
|
|
|
// Ensure a block exists to attach rendered output to
|
|
if (m_blocks.isEmpty()) {
|
|
beginInsertRows(QModelIndex(), 0, 0);
|
|
m_blocks.append({ChatBlock::Default, QString(), QString(), QString()});
|
|
endInsertRows();
|
|
emit countChanged();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Opening interrupt
|
|
if (s_interruptOpenRe.match(line).hasMatch()) {
|
|
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
|
|
m_blocks.append({ChatBlock::Interrupt, QString(), QString(), QString()});
|
|
endInsertRows();
|
|
emit countChanged();
|
|
m_state = State::InBlock;
|
|
return;
|
|
}
|
|
|
|
// [[[type]]] header line
|
|
QRegularExpressionMatch m = s_headerRe.match(line);
|
|
if (m.hasMatch()) {
|
|
ChatBlock::Type type = blockTypeFromString(m.captured(1));
|
|
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
|
|
m_blocks.append({type, m.captured(2), QString(), QString()});
|
|
endInsertRows();
|
|
emit countChanged();
|
|
m_state = State::InBlock;
|
|
return;
|
|
}
|
|
|
|
// Content line outside a block — drop unless mid-stream connect
|
|
if (m_blocks.isEmpty()) {
|
|
// Mid-stream connect: first content auto-creates a Default block
|
|
beginInsertRows(QModelIndex(), 0, 0);
|
|
m_blocks.append({ChatBlock::Default, QString(), QString(), QString()});
|
|
endInsertRows();
|
|
emit countChanged();
|
|
m_state = State::InBlock;
|
|
appendContent(line);
|
|
return;
|
|
}
|
|
// Stray content between blocks is ignored
|
|
return;
|
|
}
|
|
|
|
// ────── State: InBlock ──────────────────────────────────────────
|
|
case State::InBlock: {
|
|
// [[[end]]] closes the block
|
|
if (s_endRe.match(line).hasMatch()) {
|
|
m_state = State::Outside;
|
|
return;
|
|
}
|
|
|
|
// </interrupt> closes block (regardless of actual type)
|
|
if (s_interruptCloseRe.match(line).hasMatch()) {
|
|
m_state = State::Outside;
|
|
return;
|
|
}
|
|
|
|
// Opening ``` inside a block → enter fence, preserve return context
|
|
QRegularExpressionMatch fenceMatch = s_fenceOpenRe.match(line);
|
|
if (fenceMatch.hasMatch()) {
|
|
m_fenceLang = fenceMatch.captured(1);
|
|
m_fenceBuffer.clear();
|
|
m_wasInBlock = true;
|
|
m_state = State::InFence;
|
|
return;
|
|
}
|
|
|
|
// Content line — append to current block
|
|
appendContent(line);
|
|
return;
|
|
}
|
|
}
|
|
}
|