simplify rendering
This commit is contained in:
parent
220adf9b05
commit
c983e2bb6a
|
|
@ -1,45 +1,8 @@
|
|||
#include "chatblockmodel.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QPalette>
|
||||
#include <QTextDocument>
|
||||
#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 ---
|
||||
|
||||
|
|
@ -56,23 +19,14 @@ static ChatBlock::Type blockTypeFromString(const QString &typeStr)
|
|||
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)(?::([^\]]*))?\]\]\]$)");
|
||||
R"(^\[\[\[([^\]]+)\]\]\]$)");
|
||||
|
||||
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)
|
||||
{
|
||||
|
|
@ -142,10 +96,7 @@ void ChatBlockModel::reset()
|
|||
beginResetModel();
|
||||
m_blocks.clear();
|
||||
m_lineBuffer.clear();
|
||||
m_state = State::Outside;
|
||||
m_wasInBlock = false;
|
||||
m_fenceLang.clear();
|
||||
m_fenceBuffer.clear();
|
||||
m_state = State::BlockStart;
|
||||
endResetModel();
|
||||
emit countChanged();
|
||||
}
|
||||
|
|
@ -161,76 +112,24 @@ void ChatBlockModel::trimFront(int keepCount)
|
|||
emit countChanged();
|
||||
}
|
||||
|
||||
QString ChatBlockModel::escapeHtml(const QString &text) const
|
||||
QString ChatBlockModel::renderMarkdown(const QString &markdown) 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;
|
||||
QTextDocument document;
|
||||
document.setMarkdown(markdown);
|
||||
return document.toHtml();
|
||||
}
|
||||
|
||||
void ChatBlockModel::appendContent(const QString &line)
|
||||
{
|
||||
if (m_blocks.isEmpty())
|
||||
return;
|
||||
|
||||
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();
|
||||
}
|
||||
last.renderedHtml = renderMarkdown(last.content);
|
||||
|
||||
QModelIndex idx = index(m_blocks.size() - 1);
|
||||
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole});
|
||||
}
|
||||
|
|
@ -238,136 +137,28 @@ void ChatBlockModel::appendContent(const QString &line)
|
|||
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;
|
||||
case State::BlockStart: {
|
||||
// [[[blockname]]] is the pre-transition header trigger.
|
||||
QRegularExpressionMatch match = s_headerRe.match(line);
|
||||
if (match.hasMatch()) {
|
||||
const QString blockName = match.captured(1);
|
||||
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
|
||||
m_blocks.append({blockTypeFromString(blockName.section(':', 0, 0)),
|
||||
blockName.section(':', 1), QString(), QString()});
|
||||
endInsertRows();
|
||||
emit countChanged();
|
||||
m_state = State::BlockEnd;
|
||||
}
|
||||
|
||||
// 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
|
||||
case State::BlockEnd:
|
||||
// [[[end]]] is the post-transition trigger; all other lines are Markdown.
|
||||
if (s_endRe.match(line).hasMatch()) {
|
||||
m_state = State::Outside;
|
||||
m_state = State::BlockStart;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ struct ChatBlock {
|
|||
Type type = Default;
|
||||
QString name;
|
||||
QString content;
|
||||
QString renderedHtml; // non-empty when block contains rendered fenced code/diff
|
||||
QString renderedHtml; // Markdown-rendered block content
|
||||
};
|
||||
|
||||
class ChatBlockModel : public QAbstractListModel
|
||||
|
|
@ -32,11 +32,10 @@ class ChatBlockModel : public QAbstractListModel
|
|||
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
|
||||
|
||||
public:
|
||||
// Combined parser FSM — three states, fence is a sub-state of the block context
|
||||
// Two-state parser for [[[blockname]]] ... [[[end]]] blocks.
|
||||
enum class State {
|
||||
Outside, // No block, no fence — scanning for headers
|
||||
InBlock, // Inside [[[type]]]...[[[end]]], accepting content
|
||||
InFence, // Inside ```...```, accumulating for render
|
||||
BlockStart, // Waiting for a block header
|
||||
BlockEnd // Collecting Markdown content until [[[end]]]
|
||||
};
|
||||
|
||||
enum Roles {
|
||||
|
|
@ -63,29 +62,15 @@ private:
|
|||
void processLine(const QString &line);
|
||||
void appendContent(const QString &line);
|
||||
|
||||
// Syntax highlighting (shared with Kate plugin pattern)
|
||||
QString highlightCode(const QString &code, const QString &language) const;
|
||||
QString renderDiff(const QString &diff) const;
|
||||
QString escapeHtml(const QString &text) const;
|
||||
QString renderMarkdown(const QString &markdown) const;
|
||||
|
||||
// Combined parser state
|
||||
State m_state = State::Outside;
|
||||
|
||||
// Return-context flag for InFence: were we inside a block before the fence
|
||||
// opened? Preserved across fence boundaries. Only meaningful when InFence.
|
||||
bool m_wasInBlock = false;
|
||||
|
||||
// Fence parameters (only meaningful in InFence state)
|
||||
QString m_fenceLang;
|
||||
QString m_fenceBuffer;
|
||||
State m_state = State::BlockStart;
|
||||
|
||||
QList<ChatBlock> m_blocks;
|
||||
QString m_lineBuffer;
|
||||
|
||||
static const QRegularExpression s_headerRe;
|
||||
static const QRegularExpression s_endRe;
|
||||
static const QRegularExpression s_interruptOpenRe;
|
||||
static const QRegularExpression s_interruptCloseRe;
|
||||
};
|
||||
|
||||
#endif // CHATBLOCKMODEL_H
|
||||
20
gui/main.qml
20
gui/main.qml
|
|
@ -869,27 +869,15 @@ property bool blockExpanded: {
|
|||
}
|
||||
}
|
||||
|
||||
// Rendered HTML (fenced code/diff)
|
||||
// Block content is Markdown rendered by ChatBlockModel.
|
||||
TextEdit {
|
||||
Layout.fillWidth: true
|
||||
visible: blockItem.blockExpanded && blockRenderedHtml && blockRenderedHtml.length > 0
|
||||
text: blockRenderedHtml
|
||||
visible: blockItem.blockExpanded
|
||||
text: blockRenderedHtml || ""
|
||||
textFormat: Text.RichText
|
||||
readOnly: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
font.family: theme.fontFamily || "monospace"
|
||||
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
|
||||
color: theme.textColor
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
visible: blockItem.blockExpanded && (!blockRenderedHtml || blockRenderedHtml.length === 0)
|
||||
text: blockContent || ""
|
||||
wrapMode: Text.Wrap
|
||||
textFormat: Text.PlainText
|
||||
font.family: theme.fontFamily || "monospace"
|
||||
font.family: theme.fontFamily || Qt.application.font.family
|
||||
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
|
||||
color: theme.textColor
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue