From f09c33d2d777fda560cacb2899e44451dffb9ca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 03:19:17 +0000 Subject: [PATCH 1/4] feat(ai): render HTML content in AI stream instead of raw JSON - Add RichContentRenderer for displaying page content as rendered HTML/markdown - Add RichDiffRenderer for showing visual content diffs with red/green highlighting - Update ToolCallRenderer and CompactToolCallRenderer to use new renderers - Modify read_page to return rawContent and pageId for navigation - Modify replace_lines to return oldContent and newContent for diff visualization - Page previews are clickable to navigate to the actual page https://claude.ai/code/session_01QemLDWC938Gfr7VSk44yKe --- .../tool-calls/CompactToolCallRenderer.tsx | 58 ++-- .../chat/tool-calls/RichContentRenderer.tsx | 202 +++++++++++++ .../chat/tool-calls/RichDiffRenderer.tsx | 272 ++++++++++++++++++ .../chat/tool-calls/ToolCallRenderer.tsx | 69 +++-- .../ai/shared/chat/tool-calls/index.ts | 2 + apps/web/src/lib/ai/tools/page-read-tools.ts | 5 + apps/web/src/lib/ai/tools/page-write-tools.ts | 4 + 7 files changed, 558 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx create mode 100644 apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx index fee25bf56e..927127b21f 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx @@ -20,9 +20,9 @@ import { } from 'lucide-react'; import { FileTreeRenderer } from './FileTreeRenderer'; -import { DocumentRenderer } from './DocumentRenderer'; +import { RichContentRenderer } from './RichContentRenderer'; +import { RichDiffRenderer } from './RichDiffRenderer'; import { TaskRenderer } from './TaskRenderer'; -import { getLanguageFromPath } from '@/lib/utils/formatters'; interface TreeItem { path: string; @@ -284,30 +284,50 @@ const CompactToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: stri return
; } - if (toolName === 'read_page' && result.content) { + if (toolName === 'read_page' && (result.rawContent || result.content)) { return (
-
); } - if (toolName === 'replace_lines' && result.content) { - return ( -
- -
- ); + if (toolName === 'replace_lines' && result.success) { + // Show diff if both old and new content available + if (result.oldContent && result.newContent) { + return ( +
+ +
+ ); + } + // Fallback to showing new content + if (result.newContent) { + return ( +
+ +
+ ); + } } return ( diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx new file mode 100644 index 0000000000..b109eb9fb4 --- /dev/null +++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichContentRenderer.tsx @@ -0,0 +1,202 @@ +'use client'; + +import React, { memo, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import DOMPurify from 'dompurify'; +import { FileText, ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface RichContentRendererProps { + /** Page title for display */ + title: string; + /** HTML or markdown content to render */ + content: string; + /** Page ID for navigation (optional) */ + pageId?: string; + /** Page type for display context */ + pageType?: string; + /** Whether content is markdown (will be rendered as HTML) */ + isMarkdown?: boolean; + /** Maximum height before scrolling (default: 300px) */ + maxHeight?: number; + /** Additional CSS class */ + className?: string; +} + +/** + * Strips line numbers from content formatted as "123→content" + * Used when displaying content that was returned with line numbers for AI context + */ +function stripLineNumbers(content: string): string { + return content + .split('\n') + .map(line => { + // Match pattern: number followed by → then content + const match = line.match(/^\d+→(.*)$/); + return match ? match[1] : line; + }) + .join('\n'); +} + +/** + * Simple markdown to HTML conversion for basic formatting + * Handles: bold, italic, code, links, headers, lists + */ +function markdownToHtml(markdown: string): string { + let html = markdown + // Escape HTML entities first + .replace(/&/g, '&') + .replace(//g, '>') + // Headers + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold and italic + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/___(.+?)___/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + // Code + .replace(/`([^`]+)`/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Unordered lists + .replace(/^[-*] (.+)$/gm, '
  • $1
  • ') + // Ordered lists + .replace(/^\d+\. (.+)$/gm, '
  • $1
  • ') + // Line breaks + .replace(/\n\n/g, '

    ') + .replace(/\n/g, '
    '); + + // Wrap in paragraph if not already structured + if (!html.startsWith(''; + } + + // Wrap list items in ul + html = html.replace(/(

  • .*?<\/li>)+/gs, ''); + + return html; +} + +/** + * Sanitizes HTML content for safe rendering + */ +function sanitizeHtml(html: string): string { + if (typeof window === 'undefined') { + return html; + } + + return DOMPurify.sanitize(html, { + FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'style'], + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onfocus', 'onblur'], + KEEP_CONTENT: true, + }); +} + +/** + * RichContentRenderer - Renders page content as beautiful HTML + * + * Features: + * - Renders HTML/markdown content like a real page preview + * - White background with proper typography + * - Clickable header to navigate to the page + * - Sanitized for security + */ +export const RichContentRenderer: React.FC = memo(function RichContentRenderer({ + title, + content, + pageId, + pageType, + isMarkdown = false, + maxHeight = 300, + className +}) { + const router = useRouter(); + + // Process content: strip line numbers and convert markdown if needed + const processedHtml = useMemo(() => { + // Strip line numbers if present + const rawContent = stripLineNumbers(content); + + // Convert markdown to HTML if needed + const html = isMarkdown ? markdownToHtml(rawContent) : rawContent; + + // Sanitize for security + return sanitizeHtml(html); + }, [content, isMarkdown]); + + // Check if content looks like HTML + const hasHtmlContent = useMemo(() => { + return /<[a-z][\s\S]*>/i.test(processedHtml); + }, [processedHtml]); + + const handleNavigate = () => { + if (pageId) { + router.push(`/p/${pageId}`); + } + }; + + return ( +
    + {/* Header - clickable to navigate */} + + + {/* Content - rendered HTML */} +
    +
    + {!hasHtmlContent && ( +
    {processedHtml}
    + )} +
    +
    +
    + ); +}); diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx new file mode 100644 index 0000000000..acb5afd3a6 --- /dev/null +++ b/apps/web/src/components/ai/shared/chat/tool-calls/RichDiffRenderer.tsx @@ -0,0 +1,272 @@ +'use client'; + +import React, { memo, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import DOMPurify from 'dompurify'; +import { FileEdit, ExternalLink, Plus, Minus } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface DiffChange { + type: 'add' | 'remove' | 'unchanged'; + value: string; +} + +interface RichDiffRendererProps { + /** Page title for display */ + title: string; + /** Original content before changes */ + oldContent: string; + /** New content after changes */ + newContent: string; + /** Page ID for navigation (optional) */ + pageId?: string; + /** Summary of changes (e.g., "3 lines replaced") */ + changeSummary?: string; + /** Maximum height before scrolling (default: 400px) */ + maxHeight?: number; + /** Additional CSS class */ + className?: string; +} + +/** + * Strips line numbers from content formatted as "123→content" + */ +function stripLineNumbers(content: string): string { + return content + .split('\n') + .map(line => { + const match = line.match(/^\d+→(.*)$/); + return match ? match[1] : line; + }) + .join('\n'); +} + +/** + * Simple diff algorithm using longest common subsequence approach + * Returns an array of changes with type (add/remove/unchanged) and value + */ +function computeDiff(oldText: string, newText: string): DiffChange[] { + const oldWords = oldText.split(/(\s+)/); + const newWords = newText.split(/(\s+)/); + + const changes: DiffChange[] = []; + + // Build LCS table + const m = oldWords.length; + const n = newWords.length; + const lcs: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldWords[i - 1] === newWords[j - 1]) { + lcs[i][j] = lcs[i - 1][j - 1] + 1; + } else { + lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]); + } + } + } + + // Backtrack to find diff + let i = m, j = n; + const result: DiffChange[] = []; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) { + result.unshift({ type: 'unchanged', value: oldWords[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) { + result.unshift({ type: 'add', value: newWords[j - 1] }); + j--; + } else if (i > 0) { + result.unshift({ type: 'remove', value: oldWords[i - 1] }); + i--; + } + } + + // Merge consecutive changes of the same type + for (const change of result) { + const last = changes[changes.length - 1]; + if (last && last.type === change.type) { + last.value += change.value; + } else { + changes.push({ ...change }); + } + } + + return changes; +} + +/** + * Escapes HTML entities for safe rendering + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * RichDiffRenderer - Shows beautiful visual diffs of content changes + * + * Features: + * - Visual diff with green highlights for additions, red for deletions + * - Renders content as rich text, not code + * - Clickable header to navigate to the page + * - Shows change statistics + */ +export const RichDiffRenderer: React.FC = memo(function RichDiffRenderer({ + title, + oldContent, + newContent, + pageId, + changeSummary, + maxHeight = 400, + className +}) { + const router = useRouter(); + + // Process and compute diff + const { diffHtml, stats } = useMemo(() => { + // Strip line numbers if present + const cleanOld = stripLineNumbers(oldContent || ''); + const cleanNew = stripLineNumbers(newContent || ''); + + // Compute diff + const changes = computeDiff(cleanOld, cleanNew); + + // Count additions and deletions + let additions = 0; + let deletions = 0; + + // Build HTML with diff highlighting + const parts = changes.map(change => { + const escapedValue = escapeHtml(change.value); + const htmlValue = escapedValue.replace(/\n/g, '
    '); + + switch (change.type) { + case 'add': + additions += change.value.length; + return `${htmlValue}`; + case 'remove': + deletions += change.value.length; + return `${htmlValue}`; + default: + return htmlValue; + } + }); + + return { + diffHtml: parts.join(''), + stats: { additions, deletions } + }; + }, [oldContent, newContent]); + + const handleNavigate = () => { + if (pageId) { + router.push(`/p/${pageId}`); + } + }; + + // Sanitize the diff HTML + const sanitizedHtml = useMemo(() => { + if (typeof window === 'undefined') return diffHtml; + return DOMPurify.sanitize(diffHtml, { + ALLOWED_TAGS: ['span', 'br', 'p', 'div'], + ALLOWED_ATTR: ['class'], + }); + }, [diffHtml]); + + return ( +
    + {/* Header - clickable to navigate */} + + + {/* Optional change summary */} + {changeSummary && ( +
    + {changeSummary} +
    + )} + + {/* Content with diff highlighting */} +
    +