diff --git a/crates/ironclaw_frontend/src/widget.rs b/crates/ironclaw_frontend/src/widget.rs index ef9a56fa..bc341715 100644 --- a/crates/ironclaw_frontend/src/widget.rs +++ b/crates/ironclaw_frontend/src/widget.rs @@ -49,6 +49,9 @@ pub enum WidgetSlot { StatusRight, /// Additional section in the Settings tab. SettingsSection, + /// Custom inline renderer for structured data in chat messages. + /// Registered via `IronClaw.registerChatRenderer()` on the browser side. + ChatRenderer, } /// Prefix every CSS selector with `[data-widget="{widget_id}"]` for style isolation. diff --git a/crates/ironclaw_frontend/static/app.js b/crates/ironclaw_frontend/static/app.js index 42b590a5..4f1b470f 100644 --- a/crates/ironclaw_frontend/static/app.js +++ b/crates/ironclaw_frontend/static/app.js @@ -1020,6 +1020,128 @@ function sanitizeRenderedHtml(html) { return ''; } +// ==================== Structured Data Rendering ==================== +// +// Detects JSON objects and key-value data in assistant messages and +// renders them as styled cards instead of raw text. Also supports +// extensible chat renderers via IronClaw.registerChatRenderer(). + +/** + * Post-process a .message-content element to upgrade structured data into cards. + * Runs registered chat renderers first, then falls back to built-in JSON detection. + */ +function upgradeStructuredData(contentEl) { + // 1. Run registered chat renderers + var renderers = (window.IronClaw && IronClaw._chatRenderers) || []; + for (var i = 0; i < renderers.length; i++) { + try { + if (renderers[i].match(contentEl.textContent, contentEl)) { + renderers[i].render(contentEl, contentEl.textContent); + return; // First matching renderer wins + } + } catch (e) { + console.error('[IronClaw] Chat renderer "' + renderers[i].id + '" failed:', e); + } + } + + // 2. Built-in: detect and upgrade inline JSON objects + upgradeInlineJson(contentEl); +} + +/** + * Find JSON-like objects in text nodes and replace them with styled cards. + */ +function upgradeInlineJson(contentEl) { + // Walk text content looking for JSON objects: {...} patterns + // Only process

and top-level text, not code blocks + var paragraphs = contentEl.querySelectorAll('p'); + if (paragraphs.length === 0) { + // No

tags — markdown might have produced bare text + paragraphs = [contentEl]; + } + + paragraphs.forEach(function(p) { + // Skip code blocks + if (p.closest('pre') || p.closest('code')) return; + + var html = p.innerHTML; + // Match JSON-like objects: {...} (including Python-style single quotes) + var jsonRegex = /(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})/g; + var match; + var replaced = false; + + while ((match = jsonRegex.exec(html)) !== null) { + var raw = match[1]; + // Normalize Python-style single quotes to double quotes for parsing + var normalized = raw.replace(/'/g, '"'); + try { + var obj = JSON.parse(normalized); + if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) { + var card = buildDataCard(obj); + html = html.substring(0, match.index) + card + html.substring(match.index + match[0].length); + replaced = true; + // Reset regex since we modified the string + jsonRegex.lastIndex = match.index + card.length; + } + } catch (e) { + // Not valid JSON — leave as text + } + } + + if (replaced) { + p.innerHTML = html; + } + }); +} + +/** + * Build an HTML data card from a plain object. + */ +function buildDataCard(obj) { + var keys = Object.keys(obj); + if (keys.length === 0) return ''; + + var rows = ''; + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var value = obj[key]; + var displayKey = key.replace(/_/g, ' '); + var valueClass = 'data-card-value'; + var valueHtml; + + // Special rendering for known value types + if (key === 'status' || key === 'state') { + var badgeClass = 'status-badge'; + var sv = String(value).toLowerCase(); + if (sv === 'created' || sv === 'active' || sv === 'success' || sv === 'completed' || sv === 'ok' || sv === 'running') { + badgeClass += ' status-success'; + } else if (sv === 'failed' || sv === 'error' || sv === 'cancelled' || sv === 'rejected') { + badgeClass += ' status-error'; + } else if (sv === 'pending' || sv === 'waiting' || sv === 'queued') { + badgeClass += ' status-pending'; + } + valueHtml = '' + escapeHtml(String(value)) + ''; + } else if (typeof value === 'object' && value !== null) { + valueHtml = '' + escapeHtml(JSON.stringify(value)) + ''; + } else { + // Check if value looks like a UUID or ID + var strVal = String(value); + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(strVal)) { + valueHtml = '' + escapeHtml(strVal) + ''; + } else { + valueHtml = '' + escapeHtml(strVal) + ''; + } + } + + rows += '

' + + '' + escapeHtml(displayKey) + '' + + '' + valueHtml + '' + + '
'; + } + + return '
' + rows + '
'; +} + function copyCodeBlock(btn) { const pre = btn.parentElement; const code = pre.querySelector('code'); @@ -1824,6 +1946,8 @@ function createMessageElement(role, content) { } else { div.setAttribute('data-raw', content); contentEl.innerHTML = renderMarkdown(content); + // Upgrade structured data (JSON objects, etc.) into styled cards + upgradeStructuredData(contentEl); // Syntax highlighting for code blocks if (typeof hljs !== 'undefined') { requestAnimationFrame(() => { @@ -6337,6 +6461,7 @@ document.getElementById('settings-search-input').addEventListener('input', funct window.IronClaw = window.IronClaw || {}; IronClaw.widgets = new Map(); IronClaw._widgetInitQueue = []; +IronClaw._chatRenderers = []; /** * Register a widget component. @@ -6362,6 +6487,30 @@ IronClaw.registerWidget = function(def) { } }; +/** + * Register a chat renderer for custom inline rendering of structured data. + * + * Chat renderers run against each assistant message. The first renderer + * whose `match()` returns true gets to transform the content. + * + * @param {Object} def - Renderer definition + * @param {string} def.id - Unique identifier + * @param {Function} def.match - (textContent, element) => boolean + * @param {Function} def.render - (element, textContent) => void (mutate element in place) + * @param {number} [def.priority=0] - Higher priority runs first + */ +IronClaw.registerChatRenderer = function(def) { + if (!def.id || !def.match || !def.render) { + console.error('[IronClaw] Chat renderer requires id, match, and render:', def); + return; + } + IronClaw._chatRenderers.push(def); + // Sort by priority (higher first) + IronClaw._chatRenderers.sort(function(a, b) { + return (b.priority || 0) - (a.priority || 0); + }); +}; + /** * API object exposed to widgets for safe interaction with the app. */ diff --git a/crates/ironclaw_frontend/static/style.css b/crates/ironclaw_frontend/static/style.css index 6e0dfcf6..135e605a 100644 --- a/crates/ironclaw_frontend/static/style.css +++ b/crates/ironclaw_frontend/static/style.css @@ -2106,6 +2106,90 @@ body { background: var(--accent-subtle); } +/* --- Data Cards (inline structured data) --- */ + +.data-card { + display: flex; + flex-direction: column; + gap: 0; + margin: 8px 0; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + border-left: 3px solid var(--accent); + overflow: hidden; +} + +.data-card-row { + display: flex; + align-items: baseline; + gap: var(--space-3); + padding: 6px 14px; + border-bottom: 1px solid var(--border); +} + +.data-card-row:last-child { + border-bottom: none; +} + +.data-card-label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + text-transform: capitalize; + min-width: 80px; + flex-shrink: 0; +} + +.data-card-value { + font-size: var(--text-sm); + color: var(--text-primary); + word-break: break-word; +} + +.data-card-value code { + font-family: var(--font-mono); + font-size: 12px; + padding: 1px 5px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +.data-card-id { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + padding: 1px 5px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +/* Status badges */ + +.status-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} + +.status-success { + background: rgba(52, 211, 153, 0.15); + color: var(--success, #34d399); +} + +.status-error { + background: rgba(248, 113, 113, 0.15); + color: var(--error, #f87171); +} + +.status-pending { + background: rgba(251, 191, 36, 0.15); + color: var(--warning, #fbbf24); +} + /* Clickable job rows */ .job-row { cursor: pointer;