Markdown rendering for Gemini-CLI and other Assitant messages (#89)

* Cluster Gemini messages

* Render Assitant messages as markdown
This commit is contained in:
Solomon
2025-07-08 12:25:52 +01:00
committed by GitHub
parent b1addfb7c8
commit 930b8f6146
4 changed files with 2063 additions and 8 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -24,12 +24,14 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@sentry/react": "^9.34.0",
"@sentry/vite-plugin": "^3.5.0",
"@tailwindcss/typography": "^0.5.16",
"class-variance-authority": "^0.7.0",
"click-to-react-component": "^1.1.2",
"clsx": "^2.0.0",
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"

View File

@@ -14,10 +14,14 @@ import {
AlertCircle,
ChevronRight,
ChevronUp,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import Markdown from 'react-markdown';
import { makeRequest } from '@/lib/api';
import type {
NormalizedConversation,
NormalizedEntry,
NormalizedEntryType,
ExecutionProcess,
ApiResponse,
@@ -87,6 +91,97 @@ const getContentClassName = (entryType: NormalizedEntryType) => {
return baseClasses;
};
// Configuration for Gemini message clustering
const GEMINI_CLUSTERING_CONFIG = {
enabled: true,
maxClusterSize: 5000, // Maximum characters per cluster
maxClusterCount: 50, // Maximum number of messages to cluster together
minClusterSize: 2, // Minimum number of messages to consider clustering
};
/**
* Utility function to cluster adjacent assistant messages for Gemini executor.
*
* This function merges consecutive assistant messages into larger chunks to improve
* readability while preserving the progressive nature of Gemini's output.
*
* Clustering rules:
* - Only assistant messages are clustered together
* - Non-assistant messages (errors, tool use, etc.) break clustering
* - Clusters are limited by size (characters) and count (number of messages)
* - Requires minimum of 2 messages to form a cluster
* - Original content and formatting is preserved
*
* @param entries - Original conversation entries
* @param enabled - Whether clustering is enabled
* @returns - Processed entries with clustering applied
*/
const clusterGeminiMessages = (
entries: NormalizedEntry[],
enabled: boolean
): NormalizedEntry[] => {
if (!enabled) {
return entries;
}
const clustered: NormalizedEntry[] = [];
let currentCluster: NormalizedEntry[] = [];
const flushCluster = () => {
if (currentCluster.length === 0) return;
if (currentCluster.length < GEMINI_CLUSTERING_CONFIG.minClusterSize) {
// Not enough messages to cluster, add them individually
clustered.push(...currentCluster);
} else {
// Merge multiple messages into one
// Join with newlines to preserve message boundaries and readability
const mergedContent = currentCluster
.map((entry) => entry.content)
.join('\n');
const mergedEntry: NormalizedEntry = {
timestamp: currentCluster[0].timestamp, // Use timestamp of first message
entry_type: currentCluster[0].entry_type,
content: mergedContent,
};
clustered.push(mergedEntry);
}
currentCluster = [];
};
for (const entry of entries) {
const isAssistantMessage = entry.entry_type.type === 'assistant_message';
if (isAssistantMessage) {
// Check if we can add to current cluster
const wouldExceedSize =
currentCluster.length > 0 &&
currentCluster.map((e) => e.content).join('').length +
entry.content.length >
GEMINI_CLUSTERING_CONFIG.maxClusterSize;
const wouldExceedCount =
currentCluster.length >= GEMINI_CLUSTERING_CONFIG.maxClusterCount;
if (wouldExceedSize || wouldExceedCount) {
// Flush current cluster and start new one
flushCluster();
}
currentCluster.push(entry);
} else {
// Non-assistant message, flush current cluster and add this message separately
flushCluster();
clustered.push(entry);
}
}
// Flush any remaining cluster
flushCluster();
return clustered;
};
export function NormalizedConversationViewer({
executionProcess,
projectId,
@@ -97,6 +192,9 @@ export function NormalizedConversationViewer({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedErrors, setExpandedErrors] = useState<Set<number>>(new Set());
const [clusteringEnabled, setClusteringEnabled] = useState(
GEMINI_CLUSTERING_CONFIG.enabled
);
const toggleErrorExpansion = (index: number) => {
setExpandedErrors((prev) => {
@@ -208,8 +306,50 @@ export function NormalizedConversationViewer({
);
}
// Apply clustering for Gemini executor conversations
const isGeminiExecutor = conversation.executor_type === 'gemini';
const hasAssistantMessages = conversation.entries.some(
(entry) => entry.entry_type.type === 'assistant_message'
);
const displayEntries = isGeminiExecutor
? clusterGeminiMessages(conversation.entries, clusteringEnabled)
: conversation.entries;
return (
<div>
{/* Display clustering controls for Gemini */}
{isGeminiExecutor && hasAssistantMessages && (
<div className="mb-4 p-2 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-blue-700 dark:text-blue-300">
<Bot className="h-3 w-3" />
<span>
{clusteringEnabled &&
displayEntries.length !== conversation.entries.length
? `Messages clustered for better readability (${conversation.entries.length}${displayEntries.length} messages)`
: 'Gemini message clustering'}
</span>
</div>
<button
onClick={() => setClusteringEnabled(!clusteringEnabled)}
className="flex items-center gap-1 text-xs text-blue-700 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-200 transition-colors"
title={
clusteringEnabled
? 'Disable message clustering'
: 'Enable message clustering'
}
>
{clusteringEnabled ? (
<ToggleRight className="h-4 w-4" />
) : (
<ToggleLeft className="h-4 w-4" />
)}
<span>{clusteringEnabled ? 'ON' : 'OFF'}</span>
</button>
</div>
</div>
)}
{/* Display prompt if available */}
{conversation.prompt && (
<div className="flex items-start gap-3">
@@ -226,7 +366,7 @@ export function NormalizedConversationViewer({
{/* Display conversation entries */}
<div className="space-y-2">
{conversation.entries.map((entry, index) => {
{displayEntries.map((entry, index) => {
const isErrorMessage = entry.entry_type.type === 'error_message';
const isExpanded = expandedErrors.has(index);
const hasMultipleLines =
@@ -277,7 +417,13 @@ export function NormalizedConversationViewer({
</div>
) : (
<div className={getContentClassName(entry.entry_type)}>
{entry.content}
{entry.entry_type.type === 'assistant_message' ? (
<div className="[&>p]:mb-2 [&>ul]:list-disc [&>ul]:ml-4 [&>ol]:list-decimal [&>ol]:ml-4 [&>code]:bg-muted [&>code]:px-1 [&>code]:rounded [&>pre]:bg-muted [&>pre]:p-3 [&>pre]:rounded [&>h1]:font-bold [&>h2]:font-semibold">
<Markdown>{entry.content}</Markdown>
</div>
) : (
entry.content
)}
</div>
)}
</div>

703
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff