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'});
+});