gui: incremental rendering for chat blocks (O(n) → O(1) per line)
Previously, every streamed line triggered a full rebuild of the block's HTML: reparsing all content, re-running syntax highlighting on every code fence from the start. For a block with n lines this was O(n) per line = O(n²) total. Now appendContent() uses incrementalAppend() which: - Prose: escapes only the new line and appends to the segment HTML - Code fences: saves KSyntaxHighlighting::State between lines and only highlights the new line (O(1) per line) - Fence close: finalizes segment, requests diagrams as before - Partial line display: uses cached state, no full re-highlight Full rebuildBlockHtml() is retained only for diagram reply callbacks where the entire segment needs reconstruction. Also adds BlockRenderState to ChatBlock for tracking incremental parse position, highlight state, and accumulated HTML.
This commit is contained in:
parent
022980bc40
commit
93eba4442d
|
|
@ -208,22 +208,22 @@ void ChatBlockModel::appendData(const QString &text)
|
|||
// For streaming: update current block with partial line buffer content
|
||||
// so the UI shows text as it arrives, not just after newlines.
|
||||
if (!m_lineBuffer.isEmpty() && !m_blocks.isEmpty() && m_state == State::BlockEnd) {
|
||||
ChatBlock &last = m_blocks.last();
|
||||
// Store the committed content length so we can update partial content
|
||||
QString fullContent = last.content;
|
||||
if (!fullContent.isEmpty() && !m_lineBuffer.isEmpty())
|
||||
fullContent += '\n';
|
||||
fullContent += m_lineBuffer;
|
||||
|
||||
// Temporarily set content with partial line for rendering
|
||||
QString savedContent = last.content;
|
||||
last.content = fullContent;
|
||||
rebuildBlockHtml(m_blocks.size() - 1);
|
||||
last.content = savedContent; // Restore — partial line not committed yet
|
||||
|
||||
// But emit with the rendered HTML that includes partial content
|
||||
QModelIndex idx = index(m_blocks.size() - 1);
|
||||
int blockIndex = m_blocks.size() - 1;
|
||||
ChatBlock &last = m_blocks[blockIndex];
|
||||
|
||||
// Save current segment state, render with partial line, then restore
|
||||
QList<BlockSegment> savedSegments = last.segments;
|
||||
QString savedHtml = last.renderedHtml;
|
||||
|
||||
renderLastSegmentPartial(blockIndex, m_lineBuffer);
|
||||
|
||||
// Emit the partial-updated view
|
||||
QModelIndex idx = index(blockIndex);
|
||||
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
|
||||
|
||||
// Restore — partial line not committed yet
|
||||
last.segments = savedSegments;
|
||||
last.renderedHtml = savedHtml;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -311,6 +311,11 @@ QString ChatBlockModel::highlightCode(const QString &code, const QString &langua
|
|||
return result;
|
||||
}
|
||||
|
||||
QString ChatBlockModel::highlightCodeWrapped(const QString &innerHtml) const
|
||||
{
|
||||
return QStringLiteral("<pre style=\"white-space:pre-wrap;\">") + innerHtml + QStringLiteral("</pre>");
|
||||
}
|
||||
|
||||
QString ChatBlockModel::escapeForHtml(const QString &text) const
|
||||
{
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
|
|
@ -468,6 +473,8 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
|
||||
block.renderedHtml = html;
|
||||
block.segments = segments;
|
||||
// Reset incremental state to match the full rebuild
|
||||
block.renderState = BlockRenderState();
|
||||
}
|
||||
|
||||
void ChatBlockModel::appendContent(const QString &line)
|
||||
|
|
@ -475,17 +482,259 @@ void ChatBlockModel::appendContent(const QString &line)
|
|||
if (m_blocks.isEmpty())
|
||||
return;
|
||||
|
||||
ChatBlock &last = m_blocks.last();
|
||||
int blockIndex = m_blocks.size() - 1;
|
||||
ChatBlock &last = m_blocks[blockIndex];
|
||||
if (!last.content.isEmpty())
|
||||
last.content += '\n';
|
||||
last.content += line;
|
||||
|
||||
rebuildBlockHtml(m_blocks.size() - 1);
|
||||
incrementalAppend(blockIndex, line);
|
||||
|
||||
QModelIndex idx = index(m_blocks.size() - 1);
|
||||
QModelIndex idx = index(blockIndex);
|
||||
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
|
||||
}
|
||||
|
||||
void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block)
|
||||
{
|
||||
QString html;
|
||||
for (const BlockSegment &seg : block.segments)
|
||||
html += seg.html;
|
||||
block.renderedHtml = html;
|
||||
}
|
||||
|
||||
void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
|
||||
{
|
||||
ChatBlock &block = m_blocks[blockIndex];
|
||||
BlockRenderState &rs = block.renderState;
|
||||
rs.committedLineCount++;
|
||||
|
||||
if (!rs.inFence) {
|
||||
// Check for fence open
|
||||
auto match = s_fenceOpenRe.match(line);
|
||||
if (match.hasMatch()) {
|
||||
// Flush any accumulated prose into a finalized text segment
|
||||
if (!rs.proseLines.isEmpty()) {
|
||||
// The last segment should already be Text with these prose lines;
|
||||
// it was being built up incrementally. Mark it done.
|
||||
rs.proseLines.clear();
|
||||
}
|
||||
rs.inFence = true;
|
||||
rs.fenceLang = match.captured(1);
|
||||
rs.fenceLines.clear();
|
||||
|
||||
// Add a new Text segment for the code fence (will be rebuilt each line)
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = highlightCode(QString(), rs.fenceLang);
|
||||
block.segments.append(seg);
|
||||
block.renderedHtml += seg.html;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal prose line — extend or create last text segment
|
||||
rs.proseLines.append(line);
|
||||
|
||||
// Incremental: escape only the new line and append to existing segment
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
const QString escapedLine = line.toHtmlEscaped();
|
||||
|
||||
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text
|
||||
&& !rs.inFence) {
|
||||
// Append to existing prose segment — strip closing </div>, add newline + content
|
||||
QString &segHtml = block.segments.last().html;
|
||||
// Remove trailing </div> to append
|
||||
if (segHtml.endsWith(QStringLiteral("</div>"))) {
|
||||
segHtml.chop(6);
|
||||
segHtml += '\n' + escapedLine + QStringLiteral("</div>");
|
||||
} else {
|
||||
// Shouldn't happen, but fall back to full rebuild
|
||||
segHtml = escapeForHtml(rs.proseLines.join('\n'));
|
||||
}
|
||||
} else {
|
||||
// Start a new text segment
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ escapedLine + QStringLiteral("</div>");
|
||||
block.segments.append(seg);
|
||||
}
|
||||
// Rebuild renderedHtml from segments (concatenation is cheap)
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
} else {
|
||||
// Inside a fence — check for close
|
||||
if (s_fenceCloseRe.match(line).hasMatch()) {
|
||||
// Close the fence — finalize the segment
|
||||
rs.inFence = false;
|
||||
const QString code = rs.fenceLines.join('\n');
|
||||
|
||||
if (isDiagramLanguage(rs.fenceLang)) {
|
||||
// Replace last segment with a diagram segment
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Diagram;
|
||||
seg.diagramLang = rs.fenceLang;
|
||||
seg.diagramSource = code;
|
||||
seg.html = highlightCode(code, rs.fenceLang);
|
||||
|
||||
const QString cacheKey = rs.fenceLang + QStringLiteral(":") +
|
||||
QString::fromLatin1(QCryptographicHash::hash(
|
||||
code.toUtf8(), QCryptographicHash::Md5).toHex());
|
||||
|
||||
if (m_diagramCache.contains(cacheKey)) {
|
||||
const QByteArray cached = m_diagramCache.value(cacheKey);
|
||||
if (cached.startsWith("ERROR:")) {
|
||||
// Error: keep as text segment with highlighted source
|
||||
seg.type = BlockSegment::Text;
|
||||
} else if (cached.startsWith("JPEG:")) {
|
||||
seg.diagramSvg = QStringLiteral("data:image/jpeg;base64,") +
|
||||
QString::fromLatin1(cached.mid(5).toBase64());
|
||||
} else if (cached.startsWith("PNG:")) {
|
||||
seg.diagramSvg = QStringLiteral("data:image/png;base64,") +
|
||||
QString::fromLatin1(cached.mid(4).toBase64());
|
||||
} else if (cached.startsWith("SVG:")) {
|
||||
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
|
||||
QString::fromLatin1(cached.mid(4).toBase64());
|
||||
} else {
|
||||
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
|
||||
QString::fromLatin1(cached.toBase64());
|
||||
}
|
||||
} else {
|
||||
seg.diagramLoading = true;
|
||||
requestDiagram(rs.fenceLang, code, blockIndex);
|
||||
}
|
||||
|
||||
// Replace the in-progress fence segment
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last() = seg;
|
||||
else
|
||||
block.segments.append(seg);
|
||||
} else {
|
||||
// Normal code: finalize highlighted segment
|
||||
QString codeHtml = highlightCode(code, rs.fenceLang);
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = codeHtml;
|
||||
}
|
||||
|
||||
rs.fenceLang.clear();
|
||||
rs.fenceLines.clear();
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
} else {
|
||||
// Append line to fence — incrementally highlight only the new line
|
||||
rs.fenceLines.append(line);
|
||||
|
||||
// Get highlighter definition + theme
|
||||
auto definition = m_repo->definitionForName(rs.fenceLang);
|
||||
if (!definition.isValid())
|
||||
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
|
||||
|
||||
if (definition.isValid()) {
|
||||
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
|
||||
const auto hlTheme = dark
|
||||
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
|
||||
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
|
||||
|
||||
HtmlHighlighter highlighter;
|
||||
highlighter.setDefinition(definition);
|
||||
highlighter.setTheme(hlTheme);
|
||||
highlighter.defaultColor = getDefaultTextColor();
|
||||
|
||||
// Add line break before new line (if not the first)
|
||||
if (!rs.hlHtml.isEmpty())
|
||||
rs.hlHtml += QStringLiteral("<br/>");
|
||||
|
||||
// Process only the new line, continuing from saved state
|
||||
rs.hlState = highlighter.processLine(line, rs.hlState);
|
||||
highlighter.endLine();
|
||||
rs.hlHtml += highlighter.html;
|
||||
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = highlightCodeWrapped(rs.hlHtml);
|
||||
} else {
|
||||
// No syntax def — just escape
|
||||
if (!rs.hlHtml.isEmpty())
|
||||
rs.hlHtml += '\n';
|
||||
rs.hlHtml += line.toHtmlEscaped();
|
||||
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ rs.hlHtml + QStringLiteral("</pre>");
|
||||
}
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &partialLine)
|
||||
{
|
||||
if (blockIndex < 0 || blockIndex >= m_blocks.size())
|
||||
return;
|
||||
|
||||
ChatBlock &block = m_blocks[blockIndex];
|
||||
BlockRenderState &rs = block.renderState;
|
||||
|
||||
if (rs.inFence) {
|
||||
// Show partial line appended to current fence — use cached state
|
||||
auto definition = m_repo->definitionForName(rs.fenceLang);
|
||||
if (!definition.isValid())
|
||||
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
|
||||
|
||||
if (definition.isValid()) {
|
||||
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
|
||||
const auto hlTheme = dark
|
||||
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
|
||||
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
|
||||
|
||||
HtmlHighlighter highlighter;
|
||||
highlighter.setDefinition(definition);
|
||||
highlighter.setTheme(hlTheme);
|
||||
highlighter.defaultColor = getDefaultTextColor();
|
||||
|
||||
// Process partial line from saved state (don't save back)
|
||||
QString partialHtml = rs.hlHtml;
|
||||
if (!partialHtml.isEmpty())
|
||||
partialHtml += QStringLiteral("<br/>");
|
||||
highlighter.processLine(partialLine, rs.hlState);
|
||||
highlighter.endLine();
|
||||
partialHtml += highlighter.html;
|
||||
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = highlightCodeWrapped(partialHtml);
|
||||
} else {
|
||||
// No definition — simple escape
|
||||
QString inner = rs.hlHtml;
|
||||
if (!inner.isEmpty())
|
||||
inner += '\n';
|
||||
inner += partialLine.toHtmlEscaped();
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ inner + QStringLiteral("</pre>");
|
||||
}
|
||||
} else {
|
||||
// Show partial line appended to current prose — just escape one line
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
const QString escapedPartial = partialLine.toHtmlEscaped();
|
||||
|
||||
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text) {
|
||||
// Temporarily append to the segment (caller will restore)
|
||||
QString &segHtml = block.segments.last().html;
|
||||
if (segHtml.endsWith(QStringLiteral("</div>"))) {
|
||||
segHtml.chop(6);
|
||||
segHtml += '\n' + escapedPartial + QStringLiteral("</div>");
|
||||
} else {
|
||||
segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n'));
|
||||
}
|
||||
} else {
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
|
||||
+ escapedPartial + QStringLiteral("</div>");
|
||||
block.segments.append(seg);
|
||||
}
|
||||
}
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
}
|
||||
|
||||
void ChatBlockModel::processLine(const QString &line)
|
||||
{
|
||||
switch (m_state) {
|
||||
|
|
@ -495,8 +744,10 @@ void ChatBlockModel::processLine(const QString &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()});
|
||||
ChatBlock newBlock;
|
||||
newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
|
||||
newBlock.name = blockName.section(':', 1);
|
||||
m_blocks.append(newBlock);
|
||||
endInsertRows();
|
||||
emit countChanged();
|
||||
m_state = State::BlockEnd;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
#include <QAbstractListModel>
|
||||
#include <QList>
|
||||
#include <KSyntaxHighlighting/State>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QRegularExpression>
|
||||
|
|
@ -29,6 +30,19 @@ struct BlockSegment {
|
|||
bool diagramLoading = false; // For Diagram: true if still loading
|
||||
};
|
||||
|
||||
// Incremental rendering state for a block — tracks where we left off so
|
||||
// appendContent() can extend the last segment without reparsing from scratch.
|
||||
struct BlockRenderState {
|
||||
bool inFence = false; // Currently inside a code fence
|
||||
QString fenceLang; // Language of the open fence
|
||||
QStringList fenceLines; // Accumulated lines in the open fence
|
||||
QStringList proseLines; // Accumulated prose lines (not yet flushed)
|
||||
int committedLineCount = 0; // Number of content lines already processed incrementally
|
||||
// Incremental syntax highlighting state for open code fences
|
||||
KSyntaxHighlighting::State hlState; // Highlighter state after last processed line
|
||||
QString hlHtml; // Accumulated highlighted HTML so far (without pre wrapper)
|
||||
};
|
||||
|
||||
struct ChatBlock {
|
||||
enum Type {
|
||||
User,
|
||||
|
|
@ -48,6 +62,7 @@ struct ChatBlock {
|
|||
QString content;
|
||||
QString renderedHtml; // Legacy: full HTML (kept for compatibility)
|
||||
QList<BlockSegment> segments; // Structured segments for interactive rendering
|
||||
BlockRenderState renderState; // Incremental rendering state
|
||||
};
|
||||
|
||||
class ChatBlockModel : public QAbstractListModel
|
||||
|
|
@ -92,8 +107,12 @@ private:
|
|||
void processLine(const QString &line);
|
||||
void appendContent(const QString &line);
|
||||
void rebuildBlockHtml(int blockIndex);
|
||||
void incrementalAppend(int blockIndex, const QString &line);
|
||||
void renderLastSegmentPartial(int blockIndex, const QString &partialLine);
|
||||
static void rebuildRenderedHtmlFromSegments(ChatBlock &block);
|
||||
|
||||
QString highlightCode(const QString &code, const QString &language) const;
|
||||
QString highlightCodeWrapped(const QString &innerHtml) const;
|
||||
QString escapeForHtml(const QString &text) const;
|
||||
QString getDefaultTextColor() const;
|
||||
|
||||
|
|
|
|||
Reference in New Issue