From 11fc44351a678e3e442b4499892d25054b1999d3 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Fri, 23 Oct 2026 22:18:43 +0200 Subject: [PATCH] Add account conversation tracing to admin UI --- index.html | 142 +++++++++++++++++++++--- scripts/check-frontend.sh | 7 +- tests/admin-conversation-trace.test.cjs | 33 ++++++ 3 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 tests/admin-conversation-trace.test.cjs diff --git a/index.html b/index.html index fda2acc..a5f7d9d 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,7 @@ #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 article > button { display: block; max-width: 100%; margin-block: .5rem; text-align: start; white-space: normal; } #admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; } #admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; } #player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; } @@ -166,6 +167,7 @@ let voiceDisclosureAcknowledged = false; let adminOffset = 0; const adminPageSize = 50; + let adminTraceOffset = 0; let presenceTimer = null; let playerMessagesPrimed = false; const seenPlayerMessageIds = new Set(); @@ -615,6 +617,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 +651,117 @@ 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; + const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200&offset=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); + if (conversations.length === 0) { adminElement('p', 'Det finns inga sparade dialogturer för kontot ännu.', root); 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) { adminElement('p', `Spelaren: ${payload.player_message}`, card); showedPlayerInput = true; } + const reply = payload.reply || payload.npc_reply; + if (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) adminElement('p', `Spelaren: ${failedTurnInput}`, card); + } + 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); + 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', '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 request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=${trace.limit || 200}&offset=${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(); diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index 31914a3..42e6a00 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -100,6 +100,11 @@ grep -q 'function renderDecisionTrace' index.html grep -q 'JSON.stringify(group, null, 2)' 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 '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 +118,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 diff --git a/tests/admin-conversation-trace.test.cjs b/tests/admin-conversation-trace.test.cjs new file mode 100644 index 0000000..b617516 --- /dev/null +++ b/tests/admin-conversation-trace.test.cjs @@ -0,0 +1,33 @@ +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, /\?limit=\$\{trace\.limit \|\| 200\}&offset=\$\{adminTraceOffset\}/); +});