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 += '