Compare commits

..
15 Commits
Author SHA1 Message Date
Gitea Actions 6680dd88d6 Release 0.1.77 [auto-version-bump] 2026-10-24 03:38:12 +02:00
Ein Anderssono 652afb7673 Pretty print fenced JSON in admin AI replies
Build and publish frontend / publish (push) Successful in 1m14s
2026-10-24 03:37:36 +02:00
Gitea Actions 1ba98b998d Release 0.1.76 [auto-version-bump] 2026-10-24 03:34:07 +02:00
Ein Anderssono 23d7527e4f Decode escaped text in admin JSON views
Build and publish frontend / publish (push) Successful in 53s
2026-10-24 03:33:38 +02:00
Gitea Actions 47466f4176 Release 0.1.75 [auto-version-bump] 2026-10-24 03:30:21 +02:00
Ein Anderssono 06dea70ed1 Add collapsible JSON tree to admin views
Build and publish frontend / publish (push) Successful in 54s
2026-10-24 03:29:54 +02:00
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
6 changed files with 424 additions and 29 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.71
0.1.77
+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.71
appVersion: "0.1.71"
version: 0.1.77
appVersion: "0.1.77"
+347 -21
View File
@@ -31,9 +31,51 @@
#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-reply) { display: none; }
#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 pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
#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: .65rem; border: 1px solid #cbd5e1; border-radius: .4rem; background: #f8fafc; color: #1e293b; font: .88rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
.json-tree-tools { display: flex; flex-wrap: wrap; gap: .4rem; margin-block-end: .6rem; }
.json-tree-tools button { font-size: .88rem; padding: .3rem .55rem; }
.json-tree, .json-children { display: grid; gap: .3rem; }
.json-children { margin-inline-start: .7rem; padding-inline-start: .65rem; border-inline-start: 2px solid #cbd5e1; }
.json-node { min-width: 0; }
.json-node > summary { cursor: pointer; padding: .3rem .45rem; border-radius: .25rem; background: #e2e8f0; overflow-wrap: anywhere; }
.json-node > summary:focus-visible, .json-tree-tools button:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
.json-label { color: #075985; font-weight: 750; }
.json-kind { color: #475569; font-size: .86em; }
.json-entry { display: grid; grid-template-columns: minmax(8rem, .35fr) minmax(0, 1fr); gap: .5rem; padding: .2rem .45rem; border-radius: .2rem; overflow-wrap: anywhere; }
.json-entry:nth-child(even) { background: #f1f5f9; }
.json-key { color: #075985; font-weight: 700; }
.json-value-string { color: #166534; white-space: pre-wrap; }
.json-value-number { color: #6d28d9; font-weight: 650; }
.json-value-boolean { color: #b45309; font-weight: 750; }
.json-value-null { color: #be123c; font-style: italic; }
.json-empty { padding: .25rem .65rem; color: #475569; font-style: italic; }
.json-long-value { border-inline-start: 2px solid #94a3b8; margin-inline-start: .5rem; }
.json-long-value > summary, .json-raw-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; }
.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; }
.json-children { border-inline-start-color: #475569; }
.json-node > summary { background: #263244; }
.json-label, .json-key { color: #7dd3fc; }
.json-kind, .json-empty { color: #cbd5e1; }
.json-entry:nth-child(even), .json-long-text { background: #1f2937; }
.json-value-string { color: #86efac; }
.json-value-number { color: #c4b5fd; }
.json-value-boolean { color: #fcd34d; }
.json-value-null { color: #fda4af; }
.json-raw-value { color: #cbd5e1; }
}
#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; }
@@ -184,6 +226,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;
@@ -604,12 +662,226 @@
parent.append(element);
return element;
}
function decodeEscapedSequences(text) {
const source = String(text).replace(/\\(?:\r\n|\n|\r)[ \t]*/g, '');
const escapes = {
'0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\x0b',
f: '\f', r: '\r', e: '\x1b', ' ': ' ', '"': '"', "'": "'",
'/': '/', '\\': '\\', N: '\u0085', _: '\u00a0', L: '\u2028', P: '\u2029',
};
return source.replace(/\\(U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (sequence, escape) => {
if (Object.hasOwn(escapes, escape)) return escapes[escape];
if (/^x[0-9a-fA-F]{2}$/.test(escape)) return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
if (/^u[0-9a-fA-F]{4}$/.test(escape)) return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
if (/^U[0-9a-fA-F]{8}$/.test(escape)) {
const point = Number.parseInt(escape.slice(1), 16);
if (point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)) return String.fromCodePoint(point);
}
return sequence;
});
}
function renderJsonTree(container, value) {
container.className = 'json-view';
container.setAttribute('aria-label', 'Utfällbart JSON-svar');
const tree = document.createElement('div');
tree.className = 'json-tree';
const nodeValues = new WeakMap();
const controls = document.createElement('div');
controls.className = 'json-tree-tools';
const expand = document.createElement('button');
expand.type = 'button';
expand.textContent = 'Öppna alla block';
const collapse = document.createElement('button');
collapse.type = 'button';
collapse.textContent = 'Fäll ihop nästlade block';
controls.append(expand, collapse);
container.replaceChildren(controls, tree);
const isGroup = item => item !== null && typeof item === 'object';
const groupSummary = item => Array.isArray(item)
? `Lista · ${item.length} ${item.length === 1 ? 'post' : 'poster'}`
: `Objekt · ${Object.keys(item).length} fält`;
const addRawText = (parent, value) => {
const raw = document.createElement('details');
raw.className = 'json-raw-value';
const rawSummary = document.createElement('summary');
rawSummary.textContent = 'Visa lagrad text med escape-tecken';
const rawText = document.createElement('div');
rawText.className = 'json-long-text';
rawText.textContent = value;
raw.append(rawSummary, rawText);
parent.append(raw);
};
const addLeaf = (parent, label, item) => {
const readable = typeof item === 'string' ? decodeEscapedSequences(item) : item;
if (typeof item === 'string' && item.length > 180) {
const details = document.createElement('details');
details.className = 'json-long-value';
const summary = document.createElement('summary');
summary.append(document.createTextNode(`${label ? `${label} · ` : ''}Text · ${item.length} tecken · `));
const preview = document.createElement('span');
preview.className = 'json-value-string';
const previewText = readable.slice(0, 100);
preview.textContent = `${previewText}${readable.length > 100 ? '…' : ''}`;
summary.append(preview);
const fullText = document.createElement('div');
fullText.className = 'json-long-text';
fullText.textContent = readable;
details.append(summary, fullText);
parent.append(details);
if (readable !== item) addRawText(parent, item);
return;
}
const row = document.createElement('div');
row.className = 'json-entry';
if (label) {
const key = document.createElement('span');
key.className = 'json-key';
key.textContent = label;
row.append(key);
}
const valueNode = document.createElement('span');
const type = item === null ? 'null' : typeof item;
valueNode.className = `json-value-${type === 'boolean' ? 'boolean' : type}`;
valueNode.textContent = typeof readable === 'string' ? readable : String(readable);
row.append(valueNode);
parent.append(row);
if (typeof item === 'string' && readable !== item) addRawText(parent, item);
};
const populate = (details, item, depth) => {
if (details.dataset.loaded === 'true') return;
details.dataset.loaded = 'true';
const children = document.createElement('div');
children.className = 'json-children';
if (depth >= 64) {
const note = document.createElement('div');
note.className = 'json-empty';
note.textContent = 'Ytterligare nästling döljs efter 64 nivåer.';
children.append(note);
} else {
const entries = Array.isArray(item)
? item.map((child, index) => [`[${index}]`, child])
: Object.entries(item);
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'json-empty';
empty.textContent = Array.isArray(item) ? 'Listan är tom.' : 'Objektet saknar fält.';
children.append(empty);
}
for (const [key, child] of entries) {
if (isGroup(child)) {
const nested = document.createElement('details');
nested.className = 'json-node';
nodeValues.set(nested, child);
nested.dataset.depth = String(depth + 1);
const nestedSummary = document.createElement('summary');
const label = document.createElement('span');
label.className = 'json-label';
label.textContent = key;
const kind = document.createElement('span');
kind.className = 'json-kind';
kind.textContent = ` · ${groupSummary(child)}`;
nestedSummary.append(label, kind);
nested.append(nestedSummary);
nested.addEventListener('toggle', () => {
if (nested.open) populate(nested, child, depth + 1);
});
children.append(nested);
} else {
addLeaf(children, key, child);
}
}
}
details.append(children);
};
if (isGroup(value)) {
const root = document.createElement('details');
root.className = 'json-node json-root';
root.open = true;
const summary = document.createElement('summary');
const label = document.createElement('span');
label.className = 'json-label';
label.textContent = 'Innehåll';
const kind = document.createElement('span');
kind.className = 'json-kind';
kind.textContent = ` · ${groupSummary(value)}`;
summary.append(label, kind);
root.append(summary);
root.addEventListener('toggle', () => {
if (root.open) populate(root, value, 0);
});
populate(root, value, 0);
tree.append(root);
expand.addEventListener('click', () => {
const openAll = details => {
details.open = true;
if (details === root || details.dataset.loaded === 'true') {
const childGroups = [...details.querySelectorAll(':scope > .json-children > details.json-node')];
for (const child of childGroups) {
if (child.dataset.loaded !== 'true') {
if (nodeValues.has(child)) populate(child, nodeValues.get(child), Number(child.dataset.depth || 1));
}
openAll(child);
}
}
};
openAll(root);
});
collapse.addEventListener('click', () => {
for (const nested of tree.querySelectorAll('details.json-node:not(.json-root)')) nested.open = false;
root.open = true;
});
} else {
addLeaf(tree, '', value);
expand.disabled = true;
collapse.disabled = true;
}
return container;
}
function adminJson(title, value, parent = adminContent) {
const details = adminElement('details', undefined, parent);
adminElement('summary', title, details);
adminElement('pre', JSON.stringify(value, null, 2), details);
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();
@@ -659,7 +931,7 @@
adminJson(`${profile.username} · profil och biografi`, profile);
adminTraceOffset = 0;
adminSimpleConversation = false;
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200&offset=0`);
const trace = await getAdminTrace(accountId, 200, 0);
renderAdminConversations(trace, accountId);
}
function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) {
@@ -701,7 +973,11 @@
let showedPlayerInput = false;
for (const event of group.events) {
const payload = event.payload || {};
if (payload.player_message) { adminElement('p', `Spelaren: ${payload.player_message}`, card); showedPlayerInput = true; }
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);
@@ -722,13 +998,22 @@
}
if (!showedPlayerInput) {
const failedTurnInput = [...group.interactions].reverse().map(latestPlayerText).find(Boolean);
if (failedTurnInput) adminElement('p', `Spelaren: ${failedTurnInput}`, card);
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 message of group.messages) adminElement('p', `Spelarmeddelande: ${message.message}`, card);
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));
@@ -748,7 +1033,7 @@
older.disabled = true;
older.textContent = 'Hämtar äldre samtal…';
adminTraceOffset += trace.limit || 200;
const next = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=${trace.limit || 200}&offset=${adminTraceOffset}`);
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);
});
@@ -837,7 +1122,7 @@
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 profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, '\t'); 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 => {
@@ -1000,12 +1285,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'});
@@ -1093,9 +1419,9 @@
const addValue = (name, value) => {
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);
card.append(valueHeading, pre);
const json = document.createElement('div');
renderJsonTree(json, value === undefined ? 'Ingen uppgift sparad' : value);
card.append(valueHeading, json);
};
addValue('Indata', input);
addValue('Utdata', output);
@@ -1153,10 +1479,10 @@
const raw = document.createElement('details');
raw.className = 'trace-raw';
const rawSummary = document.createElement('summary');
rawSummary.textContent = 'Visa all rå spårdata';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(group, null, 2);
raw.append(rawSummary, pre);
rawSummary.textContent = 'Visa hela spårdata';
const json = document.createElement('div');
renderJsonTree(json, group);
raw.append(rawSummary, json);
details.append(summary, chain, raw);
debugTrace.append(details);
}
@@ -1166,7 +1492,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) {
+16 -2
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,9 +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 '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
grep -q 'json-value-string' index.html
grep -q "setAttribute('aria-label', 'Utfällbart JSON-svar')" 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
+57 -2
View File
@@ -29,13 +29,68 @@ test('selected replies reveal AI, Luau and Rust provenance without injecting HTM
test('account trace can be paged until all stored turns are loaded', () => {
assert.match(page, /Visa äldre samtalsturer/);
assert.match(page, /\?limit=\$\{trace\.limit \|\| 200\}&offset=\$\{adminTraceOffset\}/);
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-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 are readable as accessible collapsible trees', () => {
assert.match(page, /function renderJsonTree/);
assert.match(page, /nested\.className = 'json-node'/);
assert.match(page, /const nodeValues = new WeakMap\(\)/);
assert.match(page, /nodeValues\.set\(nested, child\)/);
assert.match(page, /groupSummary/);
assert.match(page, /Lista · \$\{item\.length\}/);
assert.match(page, /Objekt · \$\{Object\.keys\(item\)\.length\} fält/);
assert.match(page, /Öppna alla block/);
assert.match(page, /Fäll ihop nästlade block/);
assert.match(page, /setAttribute\('aria-label', 'Utfällbart JSON-svar'\)/);
assert.match(page, /json-value-string/);
assert.match(page, /json-value-number/);
assert.match(page, /json-value-boolean/);
assert.match(page, /json-value-null/);
assert.match(page, /renderJsonTree\(json, group\)/);
assert.match(page, /rawSummary\.textContent = 'Visa hela spårdata'/);
assert.match(page, /JSON\.stringify\(npc\.profile, null, '\\t'\)/);
assert.doesNotMatch(page, /jsnview|cdn\.jsdelivr/);
});
test('admin JSON text decodes common JSON and YAML escapes while retaining the stored form', () => {
const decoder = page.match(/function decodeEscapedSequences\(text\) \{[\s\S]*?\n \}/);
assert.ok(decoder, 'escape decoder should be present');
assert.match(page, /Visa lagrad text med escape-tecken/);
assert.match(page, /decodeEscapedSequences\(item\)/);
const decode = new Function(`${decoder[0]}; return decodeEscapedSequences;`)();
assert.equal(decode(String.raw`Rad 1\nRad 2`), 'Rad 1\nRad 2');
assert.equal(decode(String.raw`Kolumn 1\tKolumn 2`), 'Kolumn 1\tKolumn 2');
assert.equal(decode(String.raw`citat: \'hej\'`), "citat: 'hej'");
assert.equal(decode(String.raw`\u00E5\x21`), 'å!');
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'});
});