From 652afb767327816d5db3c5fa5b039b2bdd4e7b69 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Sat, 24 Oct 2026 03:37:36 +0200 Subject: [PATCH] Pretty print fenced JSON in admin AI replies --- index.html | 45 ++++++++++++++++++++++++- scripts/check-frontend.sh | 4 +++ tests/admin-conversation-trace.test.cjs | 15 +++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 909a99a..4b22b4a 100644 --- a/index.html +++ b/index.html @@ -59,6 +59,9 @@ .json-long-text { margin: .2rem .5rem .5rem; padding: .5rem; max-height: 24rem; overflow: auto; background: #e2e8f0; white-space: pre-wrap; overflow-wrap: anywhere; } .json-raw-value { margin-inline-start: .5rem; color: #475569; font-size: .9em; } .json-raw-value > .json-long-text { border-inline-start: 2px solid #94a3b8; } + .admin-ai-response { margin: .35rem 0 .75rem; } + .admin-ai-response-text { white-space: pre-wrap; overflow-wrap: anywhere; } + .admin-ai-response-code { max-width: 100%; overflow: auto; padding: .65rem; border-inline-start: 2px solid #94a3b8; white-space: pre-wrap; overflow-wrap: anywhere; } @media (max-width: 42rem) { .json-entry { grid-template-columns: 1fr; gap: .05rem; } } @media (prefers-color-scheme: dark) { .json-view { border-color: #475569; background: #111827; color: #e5e7eb; } @@ -842,6 +845,43 @@ renderJsonTree(adminElement('div', undefined, details), value); return details; } + function parseAiResponseSegments(response) { + const text = String(response ?? ''); + const fencedJson = /```json\s*([\s\S]*?)```/gi; + const segments = []; + let cursor = 0; + for (const match of text.matchAll(fencedJson)) { + const index = match.index ?? cursor; + if (index > cursor) segments.push({type: 'text', value: text.slice(cursor, index)}); + const source = match[1].trim(); + try { + segments.push({type: 'json', value: JSON.parse(source)}); + } catch (_) { + segments.push({type: 'code', value: source}); + } + cursor = index + match[0].length; + } + if (cursor < text.length || segments.length === 0) segments.push({type: 'text', value: text.slice(cursor)}); + return segments; + } + function renderAdminAiResponse(parent, response) { + const segments = parseAiResponseSegments(response); + if (!segments.some(segment => segment.type === 'json' || segment.type === 'code')) return false; + const output = adminElement('section', undefined, parent); + output.className = 'admin-ai-response'; + for (const segment of segments) { + if (segment.type === 'json') { + adminJson('JSON-svar · formaterat', segment.value, output); + } else if (segment.type === 'code') { + const code = adminElement('pre', segment.value, output); + code.className = 'admin-ai-response-code'; + } else if (segment.value.trim()) { + const text = adminElement('p', decodeEscapedSequences(segment.value.trim()), output); + text.className = 'admin-ai-response-text'; + } + } + return true; + } async function loadAdminPlayers(query = '', offset = 0) { adminOffset = offset; adminContent.replaceChildren(); @@ -969,8 +1009,11 @@ } for (const interaction of group.interactions) { if (!interaction.response) continue; - const button = adminElement('button', `AI-svar · ${interaction.prompt_id} · ${interaction.outcome}: ${interaction.response}`, card); + const responseHasJson = parseAiResponseSegments(interaction.response).some(segment => segment.type === 'json' || segment.type === 'code'); + const responseButtonLabel = `AI-svar · ${interaction.prompt_id} · ${interaction.outcome}${responseHasJson ? ' · JSON' : `: ${interaction.response}`}`; + const button = adminElement('button', responseButtonLabel, card); button.type = 'button'; button.setAttribute('aria-expanded', 'false'); + if (responseHasJson) renderAdminAiResponse(card, interaction.response); const detail = adminElement('section', undefined, card); detail.hidden = true; button.addEventListener('click', () => { detail.hidden = !detail.hidden; button.setAttribute('aria-expanded', String(!detail.hidden)); diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index e3b9e5f..e1be4d0 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -103,6 +103,10 @@ grep -q 'function renderJsonTree' index.html grep -q 'function decodeEscapedSequences' index.html grep -q 'decodeEscapedSequences(item)' index.html grep -q "Visa lagrad text med escape-tecken" index.html +grep -q 'function parseAiResponseSegments' index.html +grep -q 'function renderAdminAiResponse' index.html +grep -q "JSON-svar · formaterat" index.html +grep -q 'admin-ai-response-code' index.html grep -q "'Öppna alla block'" index.html grep -q "'Fäll ihop nästlade block'" index.html grep -q "nested.className = 'json-node'" index.html diff --git a/tests/admin-conversation-trace.test.cjs b/tests/admin-conversation-trace.test.cjs index dd64e5c..372849d 100644 --- a/tests/admin-conversation-trace.test.cjs +++ b/tests/admin-conversation-trace.test.cjs @@ -79,3 +79,18 @@ test('admin JSON text decodes common JSON and YAML escapes while retaining the s assert.equal(decode(String.raw`\U0001F642`), '🙂'); assert.equal(decode(String.raw`okänt: \q`), String.raw`okänt: \q`); }); + +test('fenced JSON in AI replies is parsed for readable formatting', () => { + const parser = page.match(/function parseAiResponseSegments\(response\) \{[\s\S]*?\n \}/); + assert.ok(parser, 'AI response segment parser should be present'); + assert.match(page, /renderAdminAiResponse\(card, interaction\.response\)/); + assert.match(page, /JSON-svar · formaterat/); + assert.match(page, /admin-ai-response-code/); + const parseSegments = new Function(`${parser[0]}; return parseAiResponseSegments;`)(); + const segments = parseSegments('dialogue-turn · completed: ```json { "intent": "speak", "reply": "Rad 1\\nRad 2" }```'); + + assert.equal(segments[0].type, 'text'); + assert.equal(segments[0].value, 'dialogue-turn · completed: '); + assert.equal(segments[1].type, 'json'); + assert.deepEqual(segments[1].value, {intent: 'speak', reply: 'Rad 1\nRad 2'}); +});