Compare commits

..
13 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
6 changed files with 352 additions and 30 deletions
+1 -1
View File
@@ -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.
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.
+1 -1
View File
@@ -1 +1 @@
0.1.69
0.1.74
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator
type: application
version: 0.1.69
appVersion: "0.1.69"
version: 0.1.74
appVersion: "0.1.74"
+276 -23
View File
@@ -27,7 +27,31 @@
#dialogue li[data-role="voice-controls"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; }
#admin-content { display: grid; gap: 1rem; }
#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; }
#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; }
@@ -166,6 +190,8 @@
let voiceDisclosureAcknowledged = false;
let adminOffset = 0;
const adminPageSize = 50;
let adminTraceOffset = 0;
let adminSimpleConversation = false;
let presenceTimer = null;
let playerMessagesPrimed = false;
const seenPlayerMessageIds = new Set();
@@ -176,6 +202,22 @@
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
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 = {}) {
const line = document.createElement('li');
line.dataset.role = role;
@@ -596,10 +638,43 @@
parent.append(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) {
const details = adminElement('details', undefined, parent);
adminElement('summary', title, details);
adminElement('pre', JSON.stringify(value, null, 2), details);
appendFormattedJson(adminElement('pre', undefined, details), value);
return details;
}
async function loadAdminPlayers(query = '', offset = 0) {
@@ -615,6 +690,22 @@
search.addEventListener('submit', event => { event.preventDefault(); loadAdminPlayers(field.value, 0); });
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);
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');
for (const player of result.players) {
const item = adminElement('li', undefined, list);
@@ -633,23 +724,144 @@
adminElement('h2', 'Spelarprofil');
const profile = await request(`/api/admin/players/${encodeURIComponent(accountId)}`);
adminJson(`${profile.username} · profil och biografi`, profile);
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`);
const entries = [
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})),
...trace.player_messages.map(message => ({kind: 'Privat spelarmeddelande', at: message.recorded_at, order: message.sequence, data: message})),
...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)));
adminElement('h2', 'Kronologiskt spelarspår');
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.');
for (const [index, entry] of entries.entries()) {
const status = entry.data.execution_mode || entry.data.metadata?.decision || entry.data.outcome || 'status okänd';
adminJson(`${index + 1}. ${entry.kind} · ${status} · ${entry.at} · ${entry.order}`, entry.data);
if (entry.data.external_call && Object.keys(entry.data.external_call).length > 0) {
const call = entry.data.external_call;
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);
adminTraceOffset = 0;
adminSimpleConversation = false;
const trace = await getAdminTrace(accountId, 200, 0);
renderAdminConversations(trace, accountId);
}
function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) {
const root = existingRoot || adminElement('section');
root.replaceChildren();
const groups = new Map();
const groupFor = (correlationId, at) => {
const key = correlationId || `unlinked:${at || groups.size}`;
if (!groups.has(key)) groups.set(key, {correlationId, events: [], messages: [], interactions: [], at});
return groups.get(key);
};
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();
@@ -865,12 +1077,53 @@
messageField.disabled = false;
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() {
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();
latestAssistantText = '';
latestAssistantText = [...lines].reverse().find(line => line.role === 'assistant')?.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();
if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') {
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
@@ -959,7 +1212,7 @@
const valueHeading = document.createElement('strong');
valueHeading.textContent = name;
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);
};
addValue('Indata', input);
@@ -1020,7 +1273,7 @@
const rawSummary = document.createElement('summary');
rawSummary.textContent = 'Visa all rå spårdata';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(group, null, 2);
appendFormattedJson(pre, group);
raw.append(rawSummary, pre);
details.append(summary, chain, raw);
debugTrace.append(details);
@@ -1031,7 +1284,7 @@
debugRefreshBusy = true;
debugStatus.textContent = 'Hämtar revisionsloggad spårning…';
try {
const trace = await request('/api/admin/self/trace?limit=200');
const trace = await getAdminTrace(null, 200, 0);
renderDecisionTrace(trace);
debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`;
} catch (error) {
+15 -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 "request('/static/version.json', {cache: 'no-store'})" 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="message" name="message"' index.html
grep -q 'id="auth-intro"' index.html
@@ -95,11 +96,22 @@ grep -q 'dialogue_temporarily_unavailable:' index.html
grep -q 'id="admin-view"' index.html
grep -q 'id="play-layout"' 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 '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 '/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/rules'" index.html
grep -q "request('/api/admin/settings/logging'" index.html
@@ -113,7 +125,7 @@ grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/memories/${encodeURICompon
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 'entry.data.external_call' index.html
grep -q 'interaction.external_call' index.html
grep -q 'Externt tjänsteanrop' index.html
grep -q "request('/api/world/start'" 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\)/);
});