Add account conversation tracing to admin UI
Build and publish frontend / publish (push) Successful in 55s
Build and publish frontend / publish (push) Successful in 55s
This commit is contained in:
+127
-15
@@ -27,6 +27,7 @@
|
|||||||
#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 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-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; }
|
#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; }
|
||||||
@@ -166,6 +167,7 @@
|
|||||||
let voiceDisclosureAcknowledged = false;
|
let voiceDisclosureAcknowledged = false;
|
||||||
let adminOffset = 0;
|
let adminOffset = 0;
|
||||||
const adminPageSize = 50;
|
const adminPageSize = 50;
|
||||||
|
let adminTraceOffset = 0;
|
||||||
let presenceTimer = null;
|
let presenceTimer = null;
|
||||||
let playerMessagesPrimed = false;
|
let playerMessagesPrimed = false;
|
||||||
const seenPlayerMessageIds = new Set();
|
const seenPlayerMessageIds = new Set();
|
||||||
@@ -615,6 +617,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);
|
||||||
@@ -633,23 +651,117 @@
|
|||||||
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 = [
|
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200&offset=0`);
|
||||||
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})),
|
renderAdminConversations(trace, accountId);
|
||||||
...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}))
|
function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) {
|
||||||
].sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
const root = existingRoot || adminElement('section');
|
||||||
adminElement('h2', 'Kronologiskt spelarspår');
|
root.replaceChildren();
|
||||||
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.');
|
const groups = new Map();
|
||||||
for (const [index, entry] of entries.entries()) {
|
const groupFor = (correlationId, at) => {
|
||||||
const status = entry.data.execution_mode || entry.data.metadata?.decision || entry.data.outcome || 'status okänd';
|
const key = correlationId || `unlinked:${at || groups.size}`;
|
||||||
adminJson(`${index + 1}. ${entry.kind} · ${status} · ${entry.at} · ${entry.order}`, entry.data);
|
if (!groups.has(key)) groups.set(key, {correlationId, events: [], messages: [], interactions: [], at});
|
||||||
if (entry.data.external_call && Object.keys(entry.data.external_call).length > 0) {
|
return groups.get(key);
|
||||||
const call = entry.data.external_call;
|
};
|
||||||
const outcome = call.error || `HTTP ${call.response_status ?? 'okänt'}`;
|
for (const event of trace.events || []) groupFor(event.correlation_id, event.recorded_at).events.push(event);
|
||||||
adminJson(`Externt tjänsteanrop · ${call.service || 'okänd tjänst'} · ${outcome} · ${call.duration_ms ?? '?'} ms`, call);
|
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) {
|
async function loadAdminNpcs(query = '', offset = 0) {
|
||||||
adminContent.replaceChildren();
|
adminContent.replaceChildren();
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ grep -q 'function renderDecisionTrace' index.html
|
|||||||
grep -q 'JSON.stringify(group, null, 2)' index.html
|
grep -q 'JSON.stringify(group, null, 2)' 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 '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
|
||||||
@@ -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 '/api/admin/npcs/${encodeURIComponent(npcId)}/possessions' index.html
|
||||||
grep -q 'Egenskaper och framtida profilfält' index.html
|
grep -q 'Egenskaper och framtida profilfält' index.html
|
||||||
grep -q 'Minnesrekonstruktioner' 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 '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
|
||||||
|
|||||||
@@ -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\}/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user