Compare commits

...
15 Commits
Author SHA1 Message Date
Gitea Actions 4101be2447 Release 0.1.74 [auto-version-bump] 2026-10-24 03:26:39 +02:00
Ein Anderssono f54f672104 Match JSON indentation source literally
Build and publish frontend / publish (push) Successful in 55s
2026-10-24 03:26:12 +02:00
Ein Anderssono 0010743024 Update source checks for formatted JSON
Build and publish frontend / publish (push) Failing after 3s
2026-10-24 03:24:52 +02:00
Ein Anderssono a746c29acb Make admin JSON responses easier to scan
Build and publish frontend / publish (push) Failing after 2s
2026-10-24 03:23:37 +02:00
Gitea Actions 6a0c2fb22c Release 0.1.73 [auto-version-bump] 2026-10-24 03:17:05 +02:00
Ein Anderssono 50ec861bba Show player turns in admin reading view
Build and publish frontend / publish (push) Successful in 57s
2026-10-24 03:16:38 +02:00
Gitea Actions d6469b6a3a Release 0.1.72 [auto-version-bump] 2026-10-24 02:57:08 +02:00
Ein Anderssono 51ab6dd23f Update trace pagination check for GraphQL
Build and publish frontend / publish (push) Successful in 1m2s
2026-10-24 02:56:36 +02:00
Ein Anderssono 344996233f Read game conversations through GraphQL
Build and publish frontend / publish (push) Failing after 3s
2026-10-24 02:56:06 +02:00
Gitea Actions 2531142517 Release 0.1.71 [auto-version-bump] 2026-10-23 22:35:28 +02:00
Ein Anderssono 4405307c99 Add storyteller-only admin reading view
Build and publish frontend / publish (push) Successful in 56s
2026-10-23 22:35:02 +02:00
Gitea Actions c38be1562e Release 0.1.70 [auto-version-bump] 2026-10-23 22:31:38 +02:00
Ein Anderssono 11fc44351a Add account conversation tracing to admin UI
Build and publish frontend / publish (push) Successful in 55s
2026-10-23 22:31:08 +02:00
Gitea Actions 66cc7f8628 Release 0.1.69 [auto-version-bump] 2026-10-22 00:05:02 +02:00
Ein Anderssono 196ff1429b Add NPC editor to admin page
Build and publish frontend / publish (push) Successful in 58s
2026-10-22 00:04:31 +02:00
6 changed files with 477 additions and 31 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ The dialogue also offers **Gör om senaste** and **Torrkör senaste**. Both rese
If the player asks to find or approach someone and no NPC is present, the direct dialogue scene may propose one person and an opening line. Rust creates that NPC only for the explicit request and commits the first exchange idempotently; a later turn continues the saved conversation. Clear requests about the current location are answered from its saved facts without asking the player to restate what they want to inspect. If the player asks to find or approach someone and no NPC is present, the direct dialogue scene may propose one person and an opening line. Rust creates that NPC only for the explicit request and commits the first exchange idempotently; a later turn continues the saved conversation. Clear requests about the current location are answered from its saved facts without asking the player to restate what they want to inspect.
The separate `/admin` route provides an authenticated operations view for player search/profile/trace, YAML prompt assets, generated Luau rules with validation results, runtime debug logging, and the append-only audit log. All privileged data comes from the Rust `/api/admin/...` API; the browser never connects directly to PostgreSQL. Backend endpoints require `accounts.is_admin=true`. The Driftloggar view lets an administrator switch `debug=true` on or off without restarting pods; the setting is stored in PostgreSQL and propagated to running replicas. Verbose pod logs include request, AI-call, validation, and commit metadata but never raw player text, prompts, model responses, sessions, or secrets. Full protected prompt/provider exchanges remain in the audited player trace. The separate `/admin` route provides an authenticated operations view for player search/profile/trace, NPC selection and editing, YAML prompt assets, generated Luau rules with validation results, runtime debug logging, and the append-only audit log. NPC editing covers identity and voice, typed profile fields plus future JSON properties, existing memory reconstructions, and owned items. The UI displays current position and commitments for context. Every NPC read and write goes through Rust, checks `accounts.is_admin=true`, validates domain limits, and writes an audit record in the same transaction as each mutation; the browser never connects directly to PostgreSQL. Profile and memory writes use expected-current values to detect stale edits. The Driftloggar view lets an administrator switch `debug=true` on or off without restarting pods; the setting is stored in PostgreSQL and propagated to running replicas. Verbose pod logs include request, AI-call, validation, and commit metadata but never raw player text, prompts, model responses, sessions, or secrets. Full protected prompt/provider exchanges remain in the audited player trace.
The game is text- and dialogue-based and uses no images. Place descriptions include sound, smell, air/temperature, touch/surface, and spatial-orientation cues. Visible details may also be described in words, but are never required to understand the scene or play. The transcript is screen-reader announced. A clearly labeled **Skicka** button beside the microphone submits typed text; Enter also submits while Shift+Enter inserts a newline. Typed text, speech transcriptions, and the send button all use the same submission flow. The game is text- and dialogue-based and uses no images. Place descriptions include sound, smell, air/temperature, touch/surface, and spatial-orientation cues. Visible details may also be described in words, but are never required to understand the scene or play. The transcript is screen-reader announced. A clearly labeled **Skicka** button beside the microphone submits typed text; Enter also submits while Shift+Enter inserts a newline. Typed text, speech transcriptions, and the send button all use the same submission flow.
@@ -26,7 +26,7 @@ System voice commands require the prefix `röstkommando`: `upprepa`, `avbryt`, `
On iPhone and iPad, Safari requires a user gesture to unlock Web Audio. The frontend resumes the audio context during a trusted tap or key press and then reads generated replies automatically. Voice settings include **Testa ljudet**; if a game reply does not start, a **Läs senaste repliken** button appears in the dialogue. First use can take longer while the voice model downloads; progress and errors are announced in the dialogue. Actual iPhone/iPad playback remains a release acceptance check and must not be claimed until tested on a device. On iPhone and iPad, Safari requires a user gesture to unlock Web Audio. The frontend resumes the audio context during a trusted tap or key press and then reads generated replies automatically. Voice settings include **Testa ljudet**; if a game reply does not start, a **Läs senaste repliken** button appears in the dialogue. First use can take longer while the voice model downloads; progress and errors are announced in the dialogue. Actual iPhone/iPad playback remains a release acceptance check and must not be claimed until tested on a device.
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration. The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/graphql`, `/version`, and `/health` to the Rust service. Player dialogue is read through the authenticated, cursor-paginated GraphQL `dialogue(first, before)` query. Admin conversation traces use the audited `adminPlayerTrace(accountId, limit, offset)` GraphQL query. Writes remain explicit Rust command routes. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
The public footer displays the frontend, static-service, and API versions. It fetches `/static/version.json` without caching; the static service creates that file from its sole `VERSION` source during the same image build used for the matching Helm chart and release tag. The public footer displays the frontend, static-service, and API versions. It fetches `/static/version.json` without caching; the static service creates that file from its sole `VERSION` source during the same image build used for the matching Helm chart and release tag.
+1 -1
View File
@@ -1 +1 @@
0.1.68 0.1.74
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator description: Static web frontend for the Swedish life simulator
type: application type: application
version: 0.1.68 version: 0.1.74
appVersion: "0.1.68" appVersion: "0.1.74"
+392 -23
View File
@@ -27,7 +27,31 @@
#dialogue li[data-role="voice-controls"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; } #dialogue li[data-role="voice-controls"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; }
#admin-content { display: grid; gap: 1rem; } #admin-content { display: grid; gap: 1rem; }
#admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; } #admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; } #admin-content article > button { display: block; max-width: 100%; margin-block: .5rem; text-align: start; white-space: normal; }
#admin-content article > .admin-story-reply { display: none; }
#admin-content .admin-simple-conversation > h2,
#admin-content .admin-simple-conversation > p:not(.admin-story-empty),
#admin-content .admin-simple-conversation article > :not(.admin-story-player):not(.admin-story-reply) { display: none; }
#admin-content .admin-simple-conversation article > .admin-story-player,
#admin-content .admin-simple-conversation article > .admin-story-reply { display: block; margin-block: .85rem; white-space: pre-wrap; }
#admin-content .admin-simple-conversation article > .admin-story-player { font-weight: 650; }
#admin-content pre, #debug-trace pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
.json-view { box-sizing: border-box; margin-block: .65rem; padding: .8rem; border: 1px solid #cbd5e1; border-radius: .4rem; background: #f8fafc; color: #1e293b; tab-size: 2; font: .84rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
.json-token-key { color: #075985; font-weight: 700; }
.json-token-string { color: #166534; }
.json-token-number { color: #6d28d9; font-weight: 650; }
.json-token-boolean { color: #b45309; font-weight: 750; }
.json-token-null { color: #be123c; font-style: italic; }
.json-token-punctuation { color: #64748b; }
@media (prefers-color-scheme: dark) {
.json-view { border-color: #475569; background: #111827; color: #e5e7eb; }
.json-token-key { color: #7dd3fc; }
.json-token-string { color: #86efac; }
.json-token-number { color: #c4b5fd; }
.json-token-boolean { color: #fcd34d; }
.json-token-null { color: #fda4af; }
.json-token-punctuation { color: #94a3b8; }
}
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; } #admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; } #player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
body.debug-layout { max-width: none; margin: 0; padding: .5rem; height: 100vh; overflow: hidden; } body.debug-layout { max-width: none; margin: 0; padding: .5rem; height: 100vh; overflow: hidden; }
@@ -104,6 +128,7 @@
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p> <p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p>
<nav id="admin-toolbar" aria-label="Administrationsvyer"> <nav id="admin-toolbar" aria-label="Administrationsvyer">
<button type="button" data-admin-view="players">Spelare</button> <button type="button" data-admin-view="players">Spelare</button>
<button type="button" data-admin-view="npcs">NPC:er</button>
<button type="button" data-admin-view="prompts">Prompter</button> <button type="button" data-admin-view="prompts">Prompter</button>
<button type="button" data-admin-view="rules">Luau-regler</button> <button type="button" data-admin-view="rules">Luau-regler</button>
<button type="button" data-admin-view="logging">Driftloggar</button> <button type="button" data-admin-view="logging">Driftloggar</button>
@@ -165,6 +190,8 @@
let voiceDisclosureAcknowledged = false; let voiceDisclosureAcknowledged = false;
let adminOffset = 0; let adminOffset = 0;
const adminPageSize = 50; const adminPageSize = 50;
let adminTraceOffset = 0;
let adminSimpleConversation = false;
let presenceTimer = null; let presenceTimer = null;
let playerMessagesPrimed = false; let playerMessagesPrimed = false;
const seenPlayerMessageIds = new Set(); const seenPlayerMessageIds = new Set();
@@ -175,6 +202,22 @@
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`); if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
return body; return body;
} }
async function graphqlQuery(query, variables = {}) {
const response = await request('/graphql', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({query, variables}),
});
if (response.errors?.length) throw new Error(response.errors[0].message || 'GraphQL-förfrågan misslyckades');
return response.data;
}
async function getAdminTrace(accountId = null, limit = 200, offset = 0) {
const data = await graphqlQuery(
'query AdminPlayerTrace($accountId: String, $limit: Int!, $offset: Int!) { adminPlayerTrace(accountId: $accountId, limit: $limit, offset: $offset) }',
{accountId, limit, offset},
);
return data.adminPlayerTrace;
}
function appendLine(role, content, options = {}) { function appendLine(role, content, options = {}) {
const line = document.createElement('li'); const line = document.createElement('li');
line.dataset.role = role; line.dataset.role = role;
@@ -595,10 +638,43 @@
parent.append(element); parent.append(element);
return element; return element;
} }
function appendFormattedJson(pre, value, stringAsText = false) {
pre.className = 'json-view';
pre.setAttribute('aria-label', 'Formaterat JSON-svar');
const code = document.createElement('code');
if (stringAsText && typeof value === 'string') {
code.textContent = value;
pre.replaceChildren(code);
return pre;
}
const source = JSON.stringify(value, null, '\t') ?? 'null';
const tokens = /(\"(?:\\.|[^\"\\])*\"(?=\s*:))|(\"(?:\\.|[^\"\\])*\")|(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}\[\],:])|(\s+)/g;
let offset = 0;
for (const match of source.matchAll(tokens)) {
if (match.index > offset) code.append(document.createTextNode(source.slice(offset, match.index)));
const token = match[0];
if (match[7]) {
code.append(document.createTextNode(token));
} else {
const span = document.createElement('span');
span.className = match[1] ? 'json-token-key'
: match[2] ? 'json-token-string'
: match[3] ? 'json-token-number'
: match[4] ? 'json-token-boolean'
: match[5] ? 'json-token-null' : 'json-token-punctuation';
span.textContent = token;
code.append(span);
}
offset = match.index + token.length;
}
if (offset < source.length) code.append(document.createTextNode(source.slice(offset)));
pre.replaceChildren(code);
return pre;
}
function adminJson(title, value, parent = adminContent) { function adminJson(title, value, parent = adminContent) {
const details = adminElement('details', undefined, parent); const details = adminElement('details', undefined, parent);
adminElement('summary', title, details); adminElement('summary', title, details);
adminElement('pre', JSON.stringify(value, null, 2), details); appendFormattedJson(adminElement('pre', undefined, details), value);
return details; return details;
} }
async function loadAdminPlayers(query = '', offset = 0) { async function loadAdminPlayers(query = '', offset = 0) {
@@ -614,6 +690,22 @@
search.addEventListener('submit', event => { event.preventDefault(); loadAdminPlayers(field.value, 0); }); search.addEventListener('submit', event => { event.preventDefault(); loadAdminPlayers(field.value, 0); });
const result = await request(`/api/admin/players?q=${encodeURIComponent(query)}&limit=${adminPageSize}&offset=${offset}`); const result = await request(`/api/admin/players?q=${encodeURIComponent(query)}&limit=${adminPageSize}&offset=${offset}`);
adminElement('p', `${result.total} spelare. Visar ${result.offset + 1}–${Math.min(result.total, result.offset + result.players.length)}.`, adminContent); adminElement('p', `${result.total} spelare. Visar ${result.offset + 1}–${Math.min(result.total, result.offset + result.players.length)}.`, adminContent);
if (result.players.length > 0) {
const chooser = adminElement('form');
const chooserLabel = adminElement('label', 'Välj konto att följa', chooser);
const chooserSelect = document.createElement('select');
chooserSelect.required = true;
for (const player of result.players) {
const option = document.createElement('option');
option.value = player.account_id;
option.textContent = `${player.username} · ${player.character_name || 'ingen karaktär'}`;
chooserSelect.append(option);
}
chooserLabel.append(chooserSelect);
const chooserButton = adminElement('button', 'Visa konto och konversationer', chooser);
chooserButton.type = 'submit';
chooser.addEventListener('submit', event => { event.preventDefault(); openAdminPlayer(chooserSelect.value); });
}
const list = adminElement('ul'); const list = adminElement('ul');
for (const player of result.players) { for (const player of result.players) {
const item = adminElement('li', undefined, list); const item = adminElement('li', undefined, list);
@@ -632,23 +724,258 @@
adminElement('h2', 'Spelarprofil'); adminElement('h2', 'Spelarprofil');
const profile = await request(`/api/admin/players/${encodeURIComponent(accountId)}`); const profile = await request(`/api/admin/players/${encodeURIComponent(accountId)}`);
adminJson(`${profile.username} · profil och biografi`, profile); adminJson(`${profile.username} · profil och biografi`, profile);
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`); adminTraceOffset = 0;
const entries = [ adminSimpleConversation = false;
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})), const trace = await getAdminTrace(accountId, 200, 0);
...trace.player_messages.map(message => ({kind: 'Privat spelarmeddelande', at: message.recorded_at, order: message.sequence, data: message})), renderAdminConversations(trace, accountId);
...trace.ai_interactions.map(interaction => ({kind: `AI ${interaction.prompt_id} · ${interaction.outcome}`, at: interaction.requested_at, order: interaction.correlation_id, data: interaction})) }
].sort((a, b) => String(a.at).localeCompare(String(b.at))); function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) {
adminElement('h2', 'Kronologiskt spelarspår'); const root = existingRoot || adminElement('section');
adminElement('p', 'Registreringstid visas separat från världstid och commitordning. AI-frågor, svar och världshändelser är korrelerade där korrelations-ID finns.'); root.replaceChildren();
for (const [index, entry] of entries.entries()) { const groups = new Map();
const status = entry.data.execution_mode || entry.data.metadata?.decision || entry.data.outcome || 'status okänd'; const groupFor = (correlationId, at) => {
adminJson(`${index + 1}. ${entry.kind} · ${status} · ${entry.at} · ${entry.order}`, entry.data); const key = correlationId || `unlinked:${at || groups.size}`;
if (entry.data.external_call && Object.keys(entry.data.external_call).length > 0) { if (!groups.has(key)) groups.set(key, {correlationId, events: [], messages: [], interactions: [], at});
const call = entry.data.external_call; return groups.get(key);
const outcome = call.error || `HTTP ${call.response_status ?? 'okänt'}`; };
adminJson(`Externt tjänsteanrop · ${call.service || 'okänd tjänst'} · ${outcome} · ${call.duration_ms ?? '?'} ms`, call); for (const event of trace.events || []) groupFor(event.correlation_id, event.recorded_at).events.push(event);
for (const message of trace.player_messages || []) groupFor(message.correlation_id, message.recorded_at).messages.push(message);
for (const interaction of trace.ai_interactions || []) groupFor(interaction.correlation_id, interaction.requested_at).interactions.push(interaction);
const conversations = [...groups.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)));
adminElement('h2', 'Samtal och beslut', root);
adminElement('p', `Visar ${conversations.length} korrelerade samtalsturer från den här sidan. Välj en berättarreplik eller ett AI-svar för att öppna hela registrerade beslutskedjan. Registreringstid, världstid och commitordning visas var för sig.`, root);
const readingToggle = adminElement('button', adminSimpleConversation ? 'Visa felsökningsspår' : 'Lättläst konversation', root);
readingToggle.type = 'button';
readingToggle.setAttribute('aria-pressed', String(adminSimpleConversation));
readingToggle.addEventListener('click', () => {
adminSimpleConversation = !adminSimpleConversation;
root.classList.toggle('admin-simple-conversation', adminSimpleConversation);
readingToggle.textContent = adminSimpleConversation ? 'Visa felsökningsspår' : 'Lättläst konversation';
readingToggle.setAttribute('aria-pressed', String(adminSimpleConversation));
});
root.classList.toggle('admin-simple-conversation', adminSimpleConversation);
if (conversations.length === 0) {
const empty = adminElement('p', 'Det finns inga sparade berättarrepliker för kontot ännu.', root);
empty.className = 'admin-story-empty';
return;
}
for (const [index, group] of conversations.entries()) {
group.events.sort((a, b) => (a.commit_sequence ?? 0) - (b.commit_sequence ?? 0));
group.messages.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
group.interactions.sort((a, b) => String(a.requested_at).localeCompare(String(b.requested_at)));
const card = adminElement('article', undefined, root);
adminElement('h3', `Tur ${index + 1} · ${group.at || 'tid saknas'} · ${group.correlationId || 'utan korrelations-ID'}`, card);
let showedPlayerInput = false;
for (const event of group.events) {
const payload = event.payload || {};
if (payload.player_message) {
const playerLine = adminElement('p', `Spelaren: ${payload.player_message}`, card);
playerLine.className = 'admin-story-player';
showedPlayerInput = true;
}
const reply = payload.reply || payload.npc_reply;
if (reply) {
const story = adminElement('p', `Berättelsen: ${reply}`, card);
story.className = 'admin-story-reply';
const button = adminElement('button', `Berättaren${payload.npc_reply ? ' / NPC' : ''}: ${reply}`, card);
button.type = 'button';
button.setAttribute('aria-expanded', 'false');
const detail = adminElement('section', undefined, card); detail.hidden = true;
button.addEventListener('click', () => {
detail.hidden = !detail.hidden;
button.setAttribute('aria-expanded', String(!detail.hidden));
if (!detail.hidden && !detail.dataset.loaded) {
renderDecisionProvenance(group, event, detail);
detail.dataset.loaded = 'true';
}
});
}
}
if (!showedPlayerInput) {
const failedTurnInput = [...group.interactions].reverse().map(latestPlayerText).find(Boolean);
if (failedTurnInput) {
const playerLine = adminElement('p', `Spelaren: ${failedTurnInput}`, card);
playerLine.className = 'admin-story-player';
}
}
for (const message of group.messages) {
const playerLine = adminElement('p', `Spelare: ${message.message}`, card);
playerLine.className = 'admin-story-player';
}
for (const interaction of group.interactions) {
if (!interaction.response) continue;
const button = adminElement('button', `AI-svar · ${interaction.prompt_id} · ${interaction.outcome}: ${interaction.response}`, card);
button.type = 'button'; button.setAttribute('aria-expanded', 'false');
const detail = adminElement('section', undefined, card); detail.hidden = true;
button.addEventListener('click', () => {
detail.hidden = !detail.hidden; button.setAttribute('aria-expanded', String(!detail.hidden));
if (!detail.hidden && !detail.dataset.loaded) { renderDecisionProvenance(group, null, detail, interaction); detail.dataset.loaded = 'true'; }
});
}
if (group.events.length === 0 && group.interactions.every(item => !item.response)) {
adminElement('p', 'Ingen färdig replik. Öppna turens tekniska spår:', card);
for (const interaction of group.interactions) adminJson(`${interaction.prompt_id} · ${interaction.outcome}`, interaction, card);
} }
} }
const pageHasMore = hasMoreOverride ?? trace.has_more ?? (Math.max(trace.events?.length || 0, trace.ai_interactions?.length || 0, trace.player_messages?.length || 0) >= (trace.limit || 200));
if (pageHasMore) {
const older = adminElement('button', adminSimpleConversation ? 'Visa äldre berättarrepliker' : 'Visa äldre samtalsturer', root);
older.type = 'button';
older.addEventListener('click', async () => {
older.disabled = true;
older.textContent = 'Hämtar äldre samtal…';
adminTraceOffset += trace.limit || 200;
const next = await getAdminTrace(accountId, trace.limit || 200, adminTraceOffset);
const merge = (key, idKey) => [...(trace[key] || []), ...(next[key] || [])].filter((item, index, all) => all.findIndex(candidate => candidate[idKey] === item[idKey]) === index);
renderAdminConversations({events: merge('events', 'event_id'), ai_interactions: merge('ai_interactions', 'id'), player_messages: merge('player_messages', 'id'), limit: next.limit, offset: next.offset}, accountId, root, next.has_more);
});
}
}
function latestPlayerText(interaction) {
const messages = interaction.rendered_messages;
if (!Array.isArray(messages)) return '';
const userMessage = [...messages].reverse().find(message => message?.role === 'user');
if (!userMessage) return '';
if (typeof userMessage.content === 'string') return userMessage.content;
return userMessage.content == null ? '' : JSON.stringify(userMessage.content);
}
function renderDecisionProvenance(group, selectedEvent, container, selectedInteraction) {
adminElement('h4', 'Beslutsspår', container);
if (group.correlationId) adminElement('p', `Korrelations-ID: ${group.correlationId}`, container);
const interactions = selectedInteraction ? [selectedInteraction] : group.interactions;
for (const interaction of interactions) {
const ai = adminElement('section', undefined, container);
ai.className = 'trace-step'; ai.dataset.engine = 'ai';
adminElement('h5', `AI · ${interaction.prompt_id} v${interaction.prompt_version} · ${interaction.outcome}`, ai);
adminJson('YAML-prompt och renderad kontext', {prompt_yaml: interaction.prompt_yaml, rendered_messages: interaction.rendered_messages}, ai);
adminJson('Externt tjänsteanrop · förfrågan, svar och metadata', {external_call: interaction.external_call, response: interaction.response, outcome: interaction.outcome, metadata: interaction.metadata}, ai);
}
const events = selectedEvent ? [selectedEvent] : group.events;
for (const event of events) {
const payload = event.payload || {};
const details = payload.details || {};
const luau = details.luau_trace || payload.luau_trace;
const rust = details.rust_validation || payload.rust_validation;
if (luau) { const step = adminElement('section', undefined, container); step.className = 'trace-step'; step.dataset.engine = 'luau'; adminJson('Luau · regelindata och effektförslag', luau, step); }
if (rust) { const step = adminElement('section', undefined, container); step.className = 'trace-step'; step.dataset.engine = 'rust'; adminJson('Rust · validering och commit', rust, step); }
const commit = adminElement('section', undefined, container); commit.className = 'trace-step'; commit.dataset.engine = 'rust';
adminJson(`Rust · ${event.commit_status || 'händelse'} · världstid ${event.world_time ?? 'okänd'} · commit ${event.commit_sequence ?? 'saknas'}`, event, commit);
}
if (!interactions.length && !events.length) adminElement('p', 'Beslutsspår saknas för denna post.', container);
}
async function loadAdminNpcs(query = '', offset = 0) {
adminContent.replaceChildren();
const form = adminElement('form');
const label = adminElement('label', 'Sök NPC eller värld', form);
const input = document.createElement('input'); input.type = 'search'; input.maxLength = 100; input.value = query; label.append(input);
const search = adminElement('button', 'Sök', form); search.type = 'submit';
form.addEventListener('submit', event => { event.preventDefault(); loadAdminNpcs(input.value, 0); });
const result = await request(`/api/admin/npcs?q=${encodeURIComponent(query)}&limit=50&offset=${offset}`);
adminElement('p', `${result.total} NPC:er. Välj en för att granska och redigera profil, minne och ägodelar.`);
const list = adminElement('ul');
for (const npc of result.npcs) {
const item = adminElement('li', undefined, list);
const button = adminElement('button', `${npc.name} · ${npc.world} · ${npc.location}${npc.scene ? ` · ${npc.scene}` : ''}`, item);
button.type = 'button'; button.addEventListener('click', () => openAdminNpc(npc.id));
}
const paging = adminElement('div');
const previous = adminElement('button', 'Föregående sida', paging); previous.type = 'button'; previous.disabled = offset <= 0; previous.addEventListener('click', () => loadAdminNpcs(query, Math.max(0, offset - 50)));
const next = adminElement('button', 'Nästa sida', paging); next.type = 'button'; next.disabled = offset + result.npcs.length >= result.total; next.addEventListener('click', () => loadAdminNpcs(query, offset + 50));
}
async function openAdminNpc(npcId) {
adminContent.replaceChildren();
const back = adminElement('button', 'Tillbaka till NPC-listan'); back.type = 'button'; back.addEventListener('click', () => loadAdminNpcs());
const npc = await request(`/api/admin/npcs/${encodeURIComponent(npcId)}`);
adminElement('h2', `${npc.name} · ${npc.world}`);
adminElement('p', `Plats: ${npc.location}${npc.scene ? ` · ${npc.scene}` : ''}. ID och position visas som referens och ändras inte i den här vyn.`);
adminElement('h3', 'Namn, bakgrund och röstidentitet');
const identityForm = adminElement('form');
const nameLabel = adminElement('label', 'Namn ', identityForm);
const nameInput = document.createElement('input'); nameInput.required = true; nameInput.maxLength = 120; nameInput.value = npc.name; nameLabel.append(nameInput);
const biographyLabel = adminElement('label', 'Bakgrund ', identityForm);
const biographyInput = document.createElement('textarea'); biographyInput.required = true; biographyInput.maxLength = 4000; biographyInput.rows = 5; biographyInput.value = npc.biography; biographyLabel.append(biographyInput);
const voiceLabel = adminElement('label', 'Röstidentitet ', identityForm);
const voiceSelect = document.createElement('select');
for (const value of ['masculine', 'feminine', 'neutral']) { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = value === npc.voice_identity; voiceSelect.append(option); }
voiceLabel.append(voiceSelect);
const identitySave = adminElement('button', 'Spara identitet', identityForm); identitySave.type = 'submit';
const identityStatus = adminElement('p'); identityStatus.setAttribute('role', 'status');
identityForm.addEventListener('submit', async event => {
event.preventDefault(); identitySave.disabled = true;
try {
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/identity`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_name: npc.name, expected_biography: npc.biography, expected_voice_identity: npc.voice_identity, name: nameInput.value, biography: biographyInput.value, voice_identity: voiceSelect.value})});
identityStatus.textContent = 'Identiteten sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.';
} catch (error) { identityStatus.textContent = `Identiteten sparades inte: ${error.message}`; }
finally { identitySave.disabled = false; }
});
adminElement('h3', 'Egenskaper och framtida profilfält');
adminElement('p', 'Redigera profilens JSON. Kända spelvärden valideras av Rust; okända framtida egenskaper bevaras som JSON. Värld och position visas som referens och ändras inte här.');
const profileForm = adminElement('form');
const profileLabel = adminElement('label', 'NPC-profil i JSON', profileForm);
const profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, 2); profileLabel.append(profileInput);
const profileSave = adminElement('button', 'Spara egenskaper', profileForm); profileSave.type = 'submit';
const profileStatus = adminElement('p'); profileStatus.setAttribute('role', 'status');
profileForm.addEventListener('submit', async event => {
event.preventDefault(); profileSave.disabled = true;
try {
const profile = JSON.parse(profileInput.value);
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/profile`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_profile: npc.profile, profile})});
profileStatus.textContent = 'Egenskaperna sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.';
} catch (error) { profileStatus.textContent = `Egenskaperna sparades inte: ${error.message}`; }
finally { profileSave.disabled = false; }
});
adminElement('h3', 'Ägodelar');
const possessionsForm = adminElement('form');
const possessionsLabel = adminElement('label', 'En ägodel per rad: namn | antal', possessionsForm);
const possessionsInput = document.createElement('textarea'); possessionsInput.rows = 8; possessionsInput.cols = 60; possessionsInput.value = npc.possessions.map(item => `${item.name} | ${item.quantity}`).join('\n'); possessionsLabel.append(possessionsInput);
const possessionsSave = adminElement('button', 'Ersätt ägodelar', possessionsForm); possessionsSave.type = 'submit';
const possessionsStatus = adminElement('p'); possessionsStatus.setAttribute('role', 'status');
possessionsForm.addEventListener('submit', async event => {
event.preventDefault(); possessionsSave.disabled = true;
try {
const possessions = possessionsInput.value.split('\n').map(line => line.trim()).filter(Boolean).map(line => { const [name, amount] = line.split('|'); return {name: name?.trim() || '', quantity: Number(amount?.trim() || 1)}; });
const expected = npc.possessions.map(item => ({name: item.name, quantity: item.quantity}));
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/possessions`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected, possessions})});
possessionsStatus.textContent = 'Ägodelarna ersattes och revisionsloggades.';
} catch (error) { possessionsStatus.textContent = `Ägodelarna sparades inte: ${error.message}`; }
finally { possessionsSave.disabled = false; }
});
adminElement('h3', 'Minnesrekonstruktioner');
adminElement('p', 'Varje ändring behåller minnespostens källa och läggs i revisionsloggen. Ändringar påverkar NPC:ns aktiva minne direkt.');
if (npc.memories.length === 0) adminElement('p', 'Inga sparade minnen för den här NPC:n.');
for (const memory of npc.memories) {
const section = adminElement('section');
adminElement('h4', `${memory.character_name} · ${memory.layer} · ${memory.active ? 'aktivt' : 'inaktivt'} · ${memory.visibility}`, section);
adminElement('p', `Källa ${memory.source_event_id}; informationsväg ${memory.information_source}; prioritet ${memory.priority}.`, section);
const form = adminElement('form', undefined, section);
const label = adminElement('label', 'Minnestext', form);
const text = document.createElement('textarea'); text.rows = 4; text.cols = 80; text.required = true; text.maxLength = 3000; text.value = memory.text; label.append(text);
const priorityLabel = adminElement('label', ' Prioritet 0–100 ', form);
const priority = document.createElement('input'); priority.type = 'number'; priority.min = '0'; priority.max = '100'; priority.value = memory.priority; priorityLabel.append(priority);
const activeLabel = adminElement('label', undefined, form);
const active = document.createElement('input'); active.type = 'checkbox'; active.checked = memory.active; activeLabel.append(active, document.createTextNode(' Aktivt minne'));
const visibilityLabel = adminElement('label', ' Synlighet ', form);
const visibility = document.createElement('select');
for (const value of ['private', 'confidential', 'shareable']) { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = value === memory.visibility; visibility.append(option); }
visibilityLabel.append(visibility);
const save = adminElement('button', 'Spara minnesändring', form); save.type = 'submit';
const status = adminElement('p', undefined, section); status.setAttribute('role', 'status');
form.addEventListener('submit', async event => {
event.preventDefault(); save.disabled = true;
try {
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/memories/${encodeURIComponent(memory.id)}`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_text: memory.text, expected_priority: memory.priority, expected_active: memory.active, expected_visibility: memory.visibility, text: text.value, priority: Number(priority.value), active: active.checked, visibility: visibility.value})});
status.textContent = 'Minnet sparades och revisionsloggades. Ladda om NPC:n för senaste version.';
} catch (error) { status.textContent = `Minnet sparades inte: ${error.message}`; }
finally { save.disabled = false; }
});
}
adminElement('h3', 'Löften och åtaganden');
if (npc.commitments.length === 0) adminElement('p', 'Inga registrerade åtaganden.');
for (const commitment of npc.commitments) adminJson(`${commitment.status} · ${commitment.created_at}`, commitment);
} }
async function loadAdminPrompts() { async function loadAdminPrompts() {
adminContent.replaceChildren(); adminContent.replaceChildren();
@@ -750,12 +1077,53 @@
messageField.disabled = false; messageField.disabled = false;
messageField.focus(); messageField.focus();
} }
function savedDialogueLine(line) {
const element = document.createElement('li');
element.dataset.role = line.role === 'user' ? 'user' : line.role === 'system' ? 'system' : 'assistant';
element.textContent = `${line.speaker || (line.role === 'user' ? 'Du' : 'Berättelsen')}: ${line.content}`;
return element;
}
function dialogueLinesFromPage(page) {
return page.edges.flatMap(edge => edge.node.lines);
}
function addOlderDialogueControl(cursor) {
const item = document.createElement('li');
item.dataset.role = 'system';
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Hämta äldre konversation';
button.addEventListener('click', async () => {
button.disabled = true;
button.textContent = 'Hämtar äldre konversation…';
try {
const page = (await graphqlQuery(
'query OlderDialogue($before: String!) { dialogue(first: 50, before: $before) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }',
{before: cursor},
)).dialogue;
const oldLines = dialogueLinesFromPage(page);
const fragment = document.createDocumentFragment();
for (const line of oldLines) fragment.append(savedDialogueLine(line));
item.remove();
transcript.prepend(fragment);
if (page.pageInfo.hasPreviousPage && page.pageInfo.startCursor) addOlderDialogueControl(page.pageInfo.startCursor);
} catch (error) {
button.disabled = false;
button.textContent = `Kunde inte hämta äldre repliker: ${error.message}. Försök igen`;
}
});
item.append(button);
transcript.prepend(item);
}
async function loadSavedDialogue() { async function loadSavedDialogue() {
const lines = await request('/api/dialogue'); const page = (await graphqlQuery(
'query CurrentDialogue { dialogue(first: 100) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }',
)).dialogue;
const lines = dialogueLinesFromPage(page);
transcript.replaceChildren(); transcript.replaceChildren();
latestAssistantText = ''; latestAssistantText = [...lines].reverse().find(line => line.role === 'assistant')?.content || '';
latestPlayerAction = [...lines].reverse().find(line => line.role === 'user')?.content || ''; latestPlayerAction = [...lines].reverse().find(line => line.role === 'user')?.content || '';
for (const line of lines) appendLine(line.role === 'user' ? 'user' : line.role === 'system' ? 'system' : 'assistant', line.content, {speaker: line.speaker, speak: false}); for (const line of lines) transcript.append(savedDialogueLine(line));
if (page.pageInfo.hasPreviousPage && page.pageInfo.startCursor) addOlderDialogueControl(page.pageInfo.startCursor);
refreshActionTools(); refreshActionTools();
if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') { if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') {
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'}); voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
@@ -844,7 +1212,7 @@
const valueHeading = document.createElement('strong'); const valueHeading = document.createElement('strong');
valueHeading.textContent = name; valueHeading.textContent = name;
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.textContent = typeof value === 'string' ? value : JSON.stringify(value ?? 'Ingen uppgift sparad', null, 2); appendFormattedJson(pre, value ?? 'Ingen uppgift sparad', true);
card.append(valueHeading, pre); card.append(valueHeading, pre);
}; };
addValue('Indata', input); addValue('Indata', input);
@@ -905,7 +1273,7 @@
const rawSummary = document.createElement('summary'); const rawSummary = document.createElement('summary');
rawSummary.textContent = 'Visa all rå spårdata'; rawSummary.textContent = 'Visa all rå spårdata';
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.textContent = JSON.stringify(group, null, 2); appendFormattedJson(pre, group);
raw.append(rawSummary, pre); raw.append(rawSummary, pre);
details.append(summary, chain, raw); details.append(summary, chain, raw);
debugTrace.append(details); debugTrace.append(details);
@@ -916,7 +1284,7 @@
debugRefreshBusy = true; debugRefreshBusy = true;
debugStatus.textContent = 'Hämtar revisionsloggad spårning…'; debugStatus.textContent = 'Hämtar revisionsloggad spårning…';
try { try {
const trace = await request('/api/admin/self/trace?limit=200'); const trace = await getAdminTrace(null, 200, 0);
renderDecisionTrace(trace); renderDecisionTrace(trace);
debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`; debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`;
} catch (error) { } catch (error) {
@@ -1243,6 +1611,7 @@
adminContent.setAttribute('aria-busy', 'true'); adminContent.setAttribute('aria-busy', 'true');
try { try {
if (button.dataset.adminView === 'players') await loadAdminPlayers(); if (button.dataset.adminView === 'players') await loadAdminPlayers();
else if (button.dataset.adminView === 'npcs') await loadAdminNpcs();
else if (button.dataset.adminView === 'prompts') await loadAdminPrompts(); else if (button.dataset.adminView === 'prompts') await loadAdminPrompts();
else if (button.dataset.adminView === 'rules') await loadAdminRules(); else if (button.dataset.adminView === 'rules') await loadAdminRules();
else if (button.dataset.adminView === 'logging') await loadAdminLogging(); else if (button.dataset.adminView === 'logging') await loadAdminLogging();
+23 -3
View File
@@ -19,6 +19,7 @@ grep -q "request('/version')" index.html
grep -q 'id="static-version">hämtar version…' index.html grep -q 'id="static-version">hämtar version…' index.html
grep -q "request('/static/version.json', {cache: 'no-store'})" index.html grep -q "request('/static/version.json', {cache: 'no-store'})" index.html
grep -Fq "credentials: 'same-origin'" index.html grep -Fq "credentials: 'same-origin'" index.html
grep -q "request('/graphql'" index.html
grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
grep -q 'id="message" name="message"' index.html grep -q 'id="message" name="message"' index.html
grep -q 'id="auth-intro"' index.html grep -q 'id="auth-intro"' index.html
@@ -95,17 +96,36 @@ grep -q 'dialogue_temporarily_unavailable:' index.html
grep -q 'id="admin-view"' index.html grep -q 'id="admin-view"' index.html
grep -q 'id="play-layout"' index.html grep -q 'id="play-layout"' index.html
grep -q 'id="debug-panel"' index.html grep -q 'id="debug-panel"' index.html
grep -q "request('/api/admin/self/trace?limit=200')" index.html grep -q "query AdminPlayerTrace" index.html
grep -q 'adminPlayerTrace(accountId: \$accountId, limit: \$limit, offset: \$offset)' index.html
grep -q 'function renderDecisionTrace' index.html grep -q 'function renderDecisionTrace' index.html
grep -q 'JSON.stringify(group, null, 2)' index.html grep -q 'appendFormattedJson(pre, group)' index.html
grep -Fq "JSON.stringify(value, null, '\\t')" index.html
grep -q 'json-token-key' index.html
grep -q 'json-token-string' index.html
grep -q 'debug-enabled' index.html grep -q 'debug-enabled' index.html
grep -q '/api/admin/players?' index.html grep -q '/api/admin/players?' index.html
grep -q 'Välj konto att följa' index.html
grep -q 'function renderAdminConversations' index.html
grep -q 'function renderDecisionProvenance' index.html
grep -q 'Visa äldre samtalsturer' index.html
grep -q 'Lättläst konversation' index.html
grep -q 'admin-simple-conversation' index.html
grep -q 'aria-expanded' index.html
grep -q "request('/api/admin/prompts'" index.html grep -q "request('/api/admin/prompts'" index.html
grep -q "request('/api/admin/rules'" index.html grep -q "request('/api/admin/rules'" index.html
grep -q "request('/api/admin/settings/logging'" index.html grep -q "request('/api/admin/settings/logging'" index.html
grep -q 'data-admin-view="logging"' index.html grep -q 'data-admin-view="logging"' index.html
grep -q '/api/admin/audit?limit=200' index.html grep -q '/api/admin/audit?limit=200' index.html
grep -q 'entry.data.external_call' index.html grep -q 'data-admin-view="npcs"' index.html
grep -q '/api/admin/npcs?q=' index.html
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/profile' index.html
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/identity' index.html
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/memories/${encodeURIComponent(memory.id)}' index.html
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/possessions' index.html
grep -q 'Egenskaper och framtida profilfält' index.html
grep -q 'Minnesrekonstruktioner' index.html
grep -q 'interaction.external_call' index.html
grep -q 'Externt tjänsteanrop' index.html grep -q 'Externt tjänsteanrop' index.html
grep -q "request('/api/world/start'" index.html grep -q "request('/api/world/start'" index.html
grep -q "request('/api/character'" index.html grep -q "request('/api/character'" index.html
+57
View File
@@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const page = fs.readFileSync(new URL('../index.html', `file://${__filename}`), 'utf8');
test('admin can select a searched account before opening its conversations', () => {
assert.match(page, /Välj konto att följa/);
assert.match(page, /chooserSelect\.value\);/);
assert.match(page, /\/api\/admin\/players\?q=/);
});
test('conversation transcript exposes player and narrator messages as expandable replies', () => {
assert.match(page, /function renderAdminConversations/);
assert.match(page, /payload\.player_message/);
assert.match(page, /payload\.reply \|\| payload\.npc_reply/);
assert.match(page, /aria-expanded/);
assert.match(page, /renderDecisionProvenance\(group, event, detail\)/);
});
test('selected replies reveal AI, Luau and Rust provenance without injecting HTML', () => {
assert.match(page, /function renderDecisionProvenance/);
assert.match(page, /interaction\.prompt_yaml/);
assert.match(page, /interaction\.external_call/);
assert.match(page, /details\.luau_trace/);
assert.match(page, /details\.rust_validation/);
assert.match(page, /element\.textContent = text/);
});
test('account trace can be paged until all stored turns are loaded', () => {
assert.match(page, /Visa äldre samtalsturer/);
assert.match(page, /query AdminPlayerTrace\(\$accountId: String, \$limit: Int!, \$offset: Int!\)/);
assert.match(page, /getAdminTrace\(accountId, trace\.limit \|\| 200, adminTraceOffset\)/);
});
test('admin can switch to a storyteller-only reading view and return to diagnostics', () => {
assert.match(page, /Lättläst konversation/);
assert.match(page, /Visa felsökningsspår/);
assert.match(page, /root\.classList\.toggle\('admin-simple-conversation', adminSimpleConversation\)/);
assert.match(page, /playerLine\.className = 'admin-story-player'/);
assert.match(page, /story\.className = 'admin-story-reply'/);
assert.match(page, /article > :not\(\.admin-story-player\):not\(\.admin-story-reply\)/);
assert.match(page, /article > \.admin-story-player,\s*#admin-content \.admin-simple-conversation article > \.admin-story-reply/);
});
test('admin JSON responses use tab-indented syntax-colored accessible output', () => {
assert.match(page, /function appendFormattedJson/);
assert.match(page, /JSON\.stringify\(value, null, '\\t'\)/);
assert.match(page, /json-token-key/);
assert.match(page, /json-token-string/);
assert.match(page, /json-token-number/);
assert.match(page, /json-token-boolean/);
assert.match(page, /json-token-null/);
assert.match(page, /setAttribute\('aria-label', 'Formaterat JSON-svar'\)/);
assert.match(page, /appendFormattedJson\(pre, group\)/);
});