diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index 357bcc1..f5f974b 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -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 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("
") + innerHtml + QStringLiteral("
"); +} + 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 , add newline + content + QString &segHtml = block.segments.last().html; + // Remove trailing to append + if (segHtml.endsWith(QStringLiteral(""))) { + segHtml.chop(6); + segHtml += '\n' + escapedLine + QStringLiteral(""); + } 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("
") + + escapedLine + QStringLiteral("
"); + 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("
"); + + // 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("
")
+                        + rs.hlHtml + QStringLiteral("
"); + } + 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("
"); + 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("
")
+                    + inner + QStringLiteral("
"); + } + } 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(""))) { + segHtml.chop(6); + segHtml += '\n' + escapedPartial + QStringLiteral(""); + } else { + segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n')); + } + } else { + BlockSegment seg; + seg.type = BlockSegment::Text; + seg.html = QStringLiteral("
") + + escapedPartial + QStringLiteral("
"); + 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; diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h index cfb97ec..8f56261 100644 --- a/gui/chatblockmodel.h +++ b/gui/chatblockmodel.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -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 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;